@sctg/backport-agent 0.1.0-20260529153002
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/LICENSE.md +21 -0
- package/README.md +151 -0
- package/config.example.json +63 -0
- package/customizations.example.yaml +44 -0
- package/dist/main.mjs +2741 -0
- package/dist/main.mjs.map +1 -0
- package/package.json +52 -0
package/dist/main.mjs
ADDED
|
@@ -0,0 +1,2741 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { Agent, createBuiltinTools, createUserInstructionConfigService } from "@sctg/cline-sdk";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import yaml from "js-yaml";
|
|
7
|
+
import { minimatch } from "minimatch";
|
|
8
|
+
import { createTool } from "@sctg/cline-agents";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { Octokit } from "@octokit/rest";
|
|
11
|
+
//#region src/config/schema.ts
|
|
12
|
+
/**
|
|
13
|
+
* @file config/schema.ts
|
|
14
|
+
*
|
|
15
|
+
* Zod schema for the agent's main configuration file (config.json).
|
|
16
|
+
* All fields are validated and typed at load time via `SyncConfigSchema.parse()`.
|
|
17
|
+
*
|
|
18
|
+
* The top-level object is divided into five sections:
|
|
19
|
+
* - `upstream` – coordinates of the original repository being tracked
|
|
20
|
+
* - `fork` – coordinates of the customised fork maintained by this agent
|
|
21
|
+
* - `workingDir` – filesystem location of the local checkout
|
|
22
|
+
* - `auth` – git authentication (SSH key or HTTP bearer token)
|
|
23
|
+
* - `sync` – behavioural knobs (commit limits, dry-run mode, branch names…)
|
|
24
|
+
* - `customizations` – inline or external customizations manifest (optional)
|
|
25
|
+
* - `models` – LLM model identifiers used for cheap vs. powerful inference
|
|
26
|
+
* - `validation` – shell commands executed after cherry-picking, grouped by risk level
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Full Zod validation schema for the backport-agent configuration.
|
|
30
|
+
*
|
|
31
|
+
* All nested objects have sensible defaults so that a minimal config.json only
|
|
32
|
+
* needs to specify `upstream`, `fork`, and `workingDir`.
|
|
33
|
+
*
|
|
34
|
+
* **Important — Zod v4 `.default()` behaviour:**
|
|
35
|
+
* When an entire sub-object is optional, we use `.default(() => ({} as any))`.
|
|
36
|
+
* The factory form `() => value` is required by Zod v4 (unlike v3's plain value form).
|
|
37
|
+
* The `as any` cast is intentional: each individual field already carries its own
|
|
38
|
+
* `.default(…)`, so Zod will fill in all missing keys automatically; the outer
|
|
39
|
+
* `{}` is just an empty trigger that lets the field-level defaults take effect.
|
|
40
|
+
*/
|
|
41
|
+
var SyncConfigSchema = z.object({
|
|
42
|
+
/**
|
|
43
|
+
* Coordinates of the upstream (canonical) repository.
|
|
44
|
+
* The agent fetches from this remote and picks commits out of it.
|
|
45
|
+
*/
|
|
46
|
+
upstream: z.object({
|
|
47
|
+
/** GitHub repository in `owner/repo` format, e.g. `"cline/cline"`. */
|
|
48
|
+
repo: z.string().describe("owner/repo of the upstream repository"),
|
|
49
|
+
/**
|
|
50
|
+
* Full git URL for the upstream remote, e.g. `"git@github.com:org/repo.git"` (SSH)
|
|
51
|
+
* or `"https://github.com/org/repo.git"` (HTTPS).
|
|
52
|
+
* Required when the working directory does not yet exist (for auto-clone setup).
|
|
53
|
+
* Supports any git hosting provider, not just GitHub.
|
|
54
|
+
*/
|
|
55
|
+
url: z.string().optional().describe("Full git URL (SSH or HTTPS) for the upstream remote"),
|
|
56
|
+
/** Branch on the upstream repo that the agent tracks, e.g. `"main"`. */
|
|
57
|
+
branch: z.string().describe("Upstream branch to sync from"),
|
|
58
|
+
/** Local git remote name pointing to the upstream repo. Defaults to `"upstream"`. */
|
|
59
|
+
remote: z.string().default("upstream").describe("Git remote name for upstream")
|
|
60
|
+
}),
|
|
61
|
+
/**
|
|
62
|
+
* Coordinates of the fork (customised) repository.
|
|
63
|
+
* This is where new sync branches are pushed and PRs are opened.
|
|
64
|
+
*/
|
|
65
|
+
fork: z.object({
|
|
66
|
+
/** GitHub repository in `owner/repo` format, e.g. `"TEA-ching/cline"`. */
|
|
67
|
+
repo: z.string().describe("owner/repo of the fork"),
|
|
68
|
+
/**
|
|
69
|
+
* Full git URL for cloning the fork, e.g. `"git@github.com:myuser/repo.git"` (SSH)
|
|
70
|
+
* or `"https://github.com/myuser/repo.git"` (HTTPS).
|
|
71
|
+
* If the working directory does not exist the agent will clone this URL automatically.
|
|
72
|
+
* Supports any git hosting provider, not just GitHub.
|
|
73
|
+
*/
|
|
74
|
+
url: z.string().optional().describe("Full git URL (SSH or HTTPS) used to clone the fork"),
|
|
75
|
+
/** Target branch in the fork that sync commits are based on, e.g. `"main"`. */
|
|
76
|
+
branch: z.string().describe("Fork branch to sync into"),
|
|
77
|
+
/** Local git remote name pointing to the fork. Defaults to `"origin"`. */
|
|
78
|
+
remote: z.string().default("origin").describe("Git remote name for the fork")
|
|
79
|
+
}),
|
|
80
|
+
/**
|
|
81
|
+
* Absolute filesystem path to the local git clone of the fork.
|
|
82
|
+
* All git operations are executed with this path as the working directory.
|
|
83
|
+
* Example: `"/home/ci/repos/my-fork"`.
|
|
84
|
+
* If the directory does not exist and `fork.url` is set, the agent will
|
|
85
|
+
* clone the fork automatically on startup.
|
|
86
|
+
*/
|
|
87
|
+
workingDir: z.string().describe("Absolute path to the local clone of the fork"),
|
|
88
|
+
/**
|
|
89
|
+
* Git authentication credentials.
|
|
90
|
+
*
|
|
91
|
+
* Exactly one of `sshKeyPath` or `githubToken` should be set:
|
|
92
|
+
* - `sshKeyPath` — path to an SSH private key; sets `GIT_SSH_COMMAND` for all git calls.
|
|
93
|
+
* Supports `~` expansion. Example: `"~/.ssh/id_ed25519"`.
|
|
94
|
+
* - `githubToken` — bearer token for HTTPS remotes (GitHub PAT, GitLab token, etc.);
|
|
95
|
+
* injected via `http.extraHeader`. Works with any git hosting provider.
|
|
96
|
+
* For security, prefer referencing an environment variable with the `$VAR` syntax
|
|
97
|
+
* (e.g. `"$GITHUB_TOKEN"`) instead of embedding the raw token. If omitted, the
|
|
98
|
+
* agent falls back to the `GITHUB_TOKEN` environment variable automatically.
|
|
99
|
+
*
|
|
100
|
+
* Both fields are optional — omit this section if git is already authenticated
|
|
101
|
+
* through the system SSH agent or a credential helper.
|
|
102
|
+
*/
|
|
103
|
+
auth: z.object({
|
|
104
|
+
/**
|
|
105
|
+
* Absolute (or `~`-prefixed) path to the SSH private key.
|
|
106
|
+
* Example: `"~/.ssh/id_ed25519"` or `"/home/ci/.ssh/deploy_key"`.
|
|
107
|
+
*/
|
|
108
|
+
sshKeyPath: z.string().optional().describe("Path to the SSH private key (supports ~ expansion)"),
|
|
109
|
+
/**
|
|
110
|
+
* Bearer token for HTTPS authentication.
|
|
111
|
+
* Prefix with `$` to read from an environment variable at runtime
|
|
112
|
+
* (e.g. `"$GITHUB_TOKEN"`), which avoids storing the secret in config.json.
|
|
113
|
+
*/
|
|
114
|
+
githubToken: z.string().optional().describe("HTTP bearer token; use \"$ENV_VAR\" syntax to read from an environment variable")
|
|
115
|
+
}).default(() => ({})),
|
|
116
|
+
/**
|
|
117
|
+
* Runtime behaviour settings for the sync loop.
|
|
118
|
+
* All fields have defaults, so this entire section is optional in config.json.
|
|
119
|
+
*/
|
|
120
|
+
sync: z.object({
|
|
121
|
+
/**
|
|
122
|
+
* Maximum number of agent loop iterations per run.
|
|
123
|
+
* Each iteration is one model turn (potentially invoking several tools in parallel).
|
|
124
|
+
* Increase this value for large repos or runs with many conflict resolutions.
|
|
125
|
+
* Defaults to 200.
|
|
126
|
+
*/
|
|
127
|
+
maxIterations: z.number().int().positive().default(200),
|
|
128
|
+
/** Maximum number of upstream commits to process in a single agent run. Defaults to 20. */
|
|
129
|
+
maxCommitsPerRun: z.number().int().positive().default(20),
|
|
130
|
+
/**
|
|
131
|
+
* Depth used when first fetching remote refs.
|
|
132
|
+
* Shallow enough to be fast; `ensureMergeBase` will deepen if necessary. Defaults to 200.
|
|
133
|
+
*/
|
|
134
|
+
initialFetchDepth: z.number().int().positive().default(200),
|
|
135
|
+
/**
|
|
136
|
+
* Absolute upper bound for history depth when searching for a merge-base.
|
|
137
|
+
* If the merge-base is not found within this depth, a full `--unshallow` fetch is attempted.
|
|
138
|
+
* Defaults to 4000.
|
|
139
|
+
*/
|
|
140
|
+
maxFetchDepth: z.number().int().positive().default(4e3),
|
|
141
|
+
/**
|
|
142
|
+
* Number of commits to cherry-pick before pausing for human review.
|
|
143
|
+
* Smaller batches reduce blast radius if something goes wrong. Defaults to 5.
|
|
144
|
+
*/
|
|
145
|
+
batchSize: z.number().int().positive().default(5),
|
|
146
|
+
/**
|
|
147
|
+
* When true, the agent runs all analysis steps but skips all write operations
|
|
148
|
+
* (no cherry-picks, no branch pushes, no PR creation). Defaults to false.
|
|
149
|
+
* Can also be enabled at runtime via the `DRY_RUN=true` environment variable.
|
|
150
|
+
*/
|
|
151
|
+
dryRun: z.boolean().default(false),
|
|
152
|
+
/** When true, the agent opens a draft PR after pushing the sync branch. Defaults to true. */
|
|
153
|
+
createPullRequest: z.boolean().default(true),
|
|
154
|
+
/**
|
|
155
|
+
* Prefix used when naming the auto-generated sync branch.
|
|
156
|
+
* The final branch name is `<branchPrefix><upstreamBranch>-<YYYY-MM-DD>`.
|
|
157
|
+
* Defaults to `"sync/upstream-"`.
|
|
158
|
+
*/
|
|
159
|
+
branchPrefix: z.string().default("sync/upstream-")
|
|
160
|
+
}).default(() => ({})),
|
|
161
|
+
/**
|
|
162
|
+
* LLM model identifiers for the keypoollive provider.
|
|
163
|
+
* Use a cheap/fast model for high-volume triage and a more powerful one for
|
|
164
|
+
* conflict resolution where reasoning quality matters most.
|
|
165
|
+
*/
|
|
166
|
+
models: z.object({
|
|
167
|
+
/**
|
|
168
|
+
* Model used for fast, inexpensive tasks such as summarising diffs and
|
|
169
|
+
* classifying risk alongside the deterministic rule engine.
|
|
170
|
+
* Defaults to `"mistral/devstral-latest"`.
|
|
171
|
+
*/
|
|
172
|
+
fast: z.string().default("mistral/devstral-latest").describe("Low-cost model for summaries and risk triage"),
|
|
173
|
+
/**
|
|
174
|
+
* Model used as first attempt for conflict resolution — optimised for code tasks.
|
|
175
|
+
* Falls back to `models.powerful` if this call fails.
|
|
176
|
+
* Defaults to `"mistral/devstral-latest"`.
|
|
177
|
+
*/
|
|
178
|
+
specialist: z.string().default("mistral/devstral-latest").describe("Code-specialist model for conflict resolution (first attempt)"),
|
|
179
|
+
/**
|
|
180
|
+
* Model used for complex conflict resolution that demands deeper reasoning.
|
|
181
|
+
* Invoked as a fallback when `models.specialist` fails.
|
|
182
|
+
* Defaults to `"mistral/magistral-medium-latest"`.
|
|
183
|
+
*/
|
|
184
|
+
powerful: z.string().default("mistral/magistral-medium-latest").describe("High-capability model for conflict resolution (fallback)")
|
|
185
|
+
}).default(() => ({})),
|
|
186
|
+
/**
|
|
187
|
+
* Deterministic merge-strategy overrides by file path.
|
|
188
|
+
*
|
|
189
|
+
* Each entry is either a glob pattern (matched via `minimatch`) or a regex
|
|
190
|
+
* literal in the form `/pattern/flags` (e.g. `"/^sdk\\/.*\.lock$/i"`).
|
|
191
|
+
* Patterns are tested against the repo-relative file path.
|
|
192
|
+
*
|
|
193
|
+
* When a conflicted file matches:
|
|
194
|
+
* - `ours` → the fork version (HEAD) is used as-is; AI resolution is skipped.
|
|
195
|
+
* - `theirs` → the upstream version (CHERRY_PICK_HEAD) is used as-is; AI resolution is skipped.
|
|
196
|
+
*
|
|
197
|
+
* `theirs` is checked first; if a file matches both, `theirs` wins.
|
|
198
|
+
*/
|
|
199
|
+
resolve: z.object({
|
|
200
|
+
/**
|
|
201
|
+
* Patterns for files where the fork version must always be kept.
|
|
202
|
+
* Useful for lock files, generated assets, or files maintained exclusively in the fork.
|
|
203
|
+
*/
|
|
204
|
+
ours: z.array(z.string()).default([]).describe("Glob/regex patterns — always keep fork version on conflict"),
|
|
205
|
+
/**
|
|
206
|
+
* Patterns for files where the upstream version must always be taken.
|
|
207
|
+
* Useful for changelogs, upstream-owned config files, or generated files
|
|
208
|
+
* that must not carry fork modifications.
|
|
209
|
+
*/
|
|
210
|
+
theirs: z.array(z.string()).default([]).describe("Glob/regex patterns — always take upstream version on conflict")
|
|
211
|
+
}).default(() => ({})),
|
|
212
|
+
/**
|
|
213
|
+
* Customizations manifest source.
|
|
214
|
+
*
|
|
215
|
+
* Accepts three forms:
|
|
216
|
+
* - `string` starting with `http://` or `https://` → fetched at runtime.
|
|
217
|
+
* - `string` (any other value) → treated as a local filesystem path.
|
|
218
|
+
* - `object` → the manifest is embedded directly in config.json (JSON equivalent
|
|
219
|
+
* of the YAML structure expected by `CustomizationsSchema`).
|
|
220
|
+
*
|
|
221
|
+
* When omitted the loader falls back to the `BACKPORT_CUSTOMIZATIONS` env var,
|
|
222
|
+
* then to `./customizations.yaml` in the current working directory.
|
|
223
|
+
*/
|
|
224
|
+
customizations: z.union([z.string(), z.record(z.string(), z.unknown())]).optional().describe("Path, URL, or inline object for the customizations manifest"),
|
|
225
|
+
/**
|
|
226
|
+
* Report output settings.
|
|
227
|
+
*/
|
|
228
|
+
report: z.object({
|
|
229
|
+
/**
|
|
230
|
+
* Filesystem directory where the detailed Markdown run report is written.
|
|
231
|
+
* The file name is `report.<timestamp>.md`.
|
|
232
|
+
* Defaults to the current working directory (`.`).
|
|
233
|
+
*/
|
|
234
|
+
destination: z.string().default(".").describe("Directory where detailed run reports are written") }).default(() => ({})),
|
|
235
|
+
/**
|
|
236
|
+
* Shell command suites executed after cherry-picking, indexed by risk level.
|
|
237
|
+
* Commands must match the allowlist in `validation/commands.ts` or they will
|
|
238
|
+
* be blocked at execution time.
|
|
239
|
+
*/
|
|
240
|
+
validation: z.object({
|
|
241
|
+
/**
|
|
242
|
+
* Commands run for low-risk commits (no customisation or build-critical files touched).
|
|
243
|
+
* Defaults to `["npm run typecheck"]`.
|
|
244
|
+
*/
|
|
245
|
+
low: z.array(z.string()).default(["npm run typecheck"]),
|
|
246
|
+
/**
|
|
247
|
+
* Commands run for medium-risk commits (shared/services code changed).
|
|
248
|
+
* Defaults to typecheck + unit tests.
|
|
249
|
+
*/
|
|
250
|
+
medium: z.array(z.string()).default(["npm run typecheck", "npm run test:unit"]),
|
|
251
|
+
/**
|
|
252
|
+
* Commands run for high-risk commits (customisation zones, build files, lock files…).
|
|
253
|
+
* Defaults to typecheck + unit tests + full build.
|
|
254
|
+
*/
|
|
255
|
+
high: z.array(z.string()).default([
|
|
256
|
+
"npm run typecheck",
|
|
257
|
+
"npm run test:unit",
|
|
258
|
+
"npm run build"
|
|
259
|
+
])
|
|
260
|
+
}).default(() => ({}))
|
|
261
|
+
});
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/config/loader.ts
|
|
264
|
+
/**
|
|
265
|
+
* @file config/loader.ts
|
|
266
|
+
*
|
|
267
|
+
* Loads and validates the agent's main configuration from a JSON file.
|
|
268
|
+
*
|
|
269
|
+
* Resolution order for the config path (first match wins):
|
|
270
|
+
* 1. Explicit `configPath` argument passed by the caller.
|
|
271
|
+
* 2. The `BACKPORT_CONFIG` environment variable.
|
|
272
|
+
* 3. `config.json` in the current working directory.
|
|
273
|
+
*
|
|
274
|
+
* Environment variable overrides applied after parsing:
|
|
275
|
+
* - `DRY_RUN=true` → forces `sync.dryRun = true` regardless of the JSON value.
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* Read, parse, and validate the agent configuration file.
|
|
279
|
+
*
|
|
280
|
+
* The raw JSON is parsed first, then any environment-variable overrides are
|
|
281
|
+
* merged in before the result is validated through `SyncConfigSchema.parse()`.
|
|
282
|
+
* Zod will throw a descriptive `ZodError` if required fields are missing or
|
|
283
|
+
* have the wrong type.
|
|
284
|
+
*
|
|
285
|
+
* @param configPath - Optional explicit path to a `config.json` file.
|
|
286
|
+
* Falls back to `BACKPORT_CONFIG` env var, then `./config.json`.
|
|
287
|
+
* @returns A fully validated `SyncConfig` object with all defaults applied.
|
|
288
|
+
* @throws {Error} If the file cannot be read or cannot be parsed as JSON.
|
|
289
|
+
* @throws {ZodError} If the JSON structure does not satisfy `SyncConfigSchema`.
|
|
290
|
+
*/
|
|
291
|
+
function loadConfig(configPath) {
|
|
292
|
+
const path = configPath ?? process.env.BACKPORT_CONFIG ?? resolve(process.cwd(), "config.json");
|
|
293
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
294
|
+
raw.sync ??= {};
|
|
295
|
+
raw.models ??= {};
|
|
296
|
+
raw.validation ??= {};
|
|
297
|
+
if (process.env.KEYPOOL_VAULT_URL) {}
|
|
298
|
+
if (process.env.DRY_RUN === "true") raw.sync = {
|
|
299
|
+
...raw.sync ?? {},
|
|
300
|
+
dryRun: true
|
|
301
|
+
};
|
|
302
|
+
return SyncConfigSchema.parse(raw);
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/customizations/schema.ts
|
|
306
|
+
/**
|
|
307
|
+
* @file customizations/schema.ts
|
|
308
|
+
*
|
|
309
|
+
* Zod schema for the agent's customizations manifest (customizations.yaml).
|
|
310
|
+
*
|
|
311
|
+
* Each "customization entry" describes a deliberate deviation from upstream:
|
|
312
|
+
* which file paths it covers, what invariants must remain intact after a sync,
|
|
313
|
+
* and optional shell commands that can verify the customization is still working.
|
|
314
|
+
*
|
|
315
|
+
* The agent uses this manifest to:
|
|
316
|
+
* 1. Detect when an upstream commit touches a customization zone (risk classification).
|
|
317
|
+
* 2. Guide conflict resolution — the LLM knows which files carry fork-specific logic.
|
|
318
|
+
* 3. Produce human-readable PR comments that explain why certain files need review.
|
|
319
|
+
*/
|
|
320
|
+
/**
|
|
321
|
+
* Schema for a single customization entry in the manifest.
|
|
322
|
+
*
|
|
323
|
+
* Example YAML entry:
|
|
324
|
+
* ```yaml
|
|
325
|
+
* - id: keypoollive-provider-vscode
|
|
326
|
+
* description: "Registers the keypoollive LLM provider inside the VS Code extension"
|
|
327
|
+
* paths:
|
|
328
|
+
* - src/api/providers/keypoollive.ts
|
|
329
|
+
* - src/shared/providers/providers.json
|
|
330
|
+
* invariants:
|
|
331
|
+
* - "keypoollive must remain listed in providers.json"
|
|
332
|
+
* testCommands:
|
|
333
|
+
* - "npm run typecheck"
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
var CustomizationEntrySchema = z.object({
|
|
337
|
+
/**
|
|
338
|
+
* Short machine-readable identifier for this customization, e.g. `"keypoollive-provider-vscode"`.
|
|
339
|
+
* Used in risk reports and decision logs to unambiguously reference the entry.
|
|
340
|
+
*/
|
|
341
|
+
id: z.string(),
|
|
342
|
+
/**
|
|
343
|
+
* Human-readable description of what this customization does and why it exists.
|
|
344
|
+
* Surfaced in PR comments and agent decision logs.
|
|
345
|
+
*/
|
|
346
|
+
description: z.string(),
|
|
347
|
+
/**
|
|
348
|
+
* Glob patterns (relative to the repository root) that cover the files owned
|
|
349
|
+
* by this customization. Any upstream commit touching one of these paths will
|
|
350
|
+
* be classified as high risk.
|
|
351
|
+
*
|
|
352
|
+
* Standard minimatch syntax is supported, e.g. `"src/api/providers/keypoollive/**"`.
|
|
353
|
+
*/
|
|
354
|
+
paths: z.array(z.string()).describe("Glob patterns relative to repo root"),
|
|
355
|
+
/**
|
|
356
|
+
* Ordered list of invariants that must remain true after every sync.
|
|
357
|
+
* The agent checks these conceptually during conflict resolution and includes
|
|
358
|
+
* them in the PR body so human reviewers know what to verify.
|
|
359
|
+
*
|
|
360
|
+
* Example: `"The SCTG_KEY_VAULT_URL constant must not be removed."`
|
|
361
|
+
*/
|
|
362
|
+
invariants: z.array(z.string()).describe("Human-readable invariants that must remain true after sync"),
|
|
363
|
+
/**
|
|
364
|
+
* Optional shell commands to run in order to verify this specific customization
|
|
365
|
+
* is still intact after a sync. These are appended to the validation suite when
|
|
366
|
+
* the commit risk level is "high" and this customization is affected.
|
|
367
|
+
*
|
|
368
|
+
* Commands must still match the global allowlist in `validation/commands.ts`.
|
|
369
|
+
*/
|
|
370
|
+
testCommands: z.array(z.string()).optional().describe("Commands to verify this customization still works")
|
|
371
|
+
});
|
|
372
|
+
/**
|
|
373
|
+
* Schema for the entire customizations manifest file.
|
|
374
|
+
* The top-level key `customizations` holds the array of entries.
|
|
375
|
+
*/
|
|
376
|
+
var CustomizationsSchema = z.object({
|
|
377
|
+
/** Array of all known fork customizations. May be empty if the fork has no deviations. */
|
|
378
|
+
customizations: z.array(CustomizationEntrySchema) });
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/customizations/loader.ts
|
|
381
|
+
/**
|
|
382
|
+
* @file customizations/loader.ts
|
|
383
|
+
*
|
|
384
|
+
* Loads and validates the customizations manifest from multiple sources.
|
|
385
|
+
*
|
|
386
|
+
* Resolution order for the manifest (first match wins):
|
|
387
|
+
* 1. Explicit `source` argument passed by the caller.
|
|
388
|
+
* - `string` starting with `http://` or `https://` → fetched via HTTP GET.
|
|
389
|
+
* - `string` (other) → read from the local filesystem.
|
|
390
|
+
* - `object` → used directly as the parsed manifest (JSON/inline form).
|
|
391
|
+
* 2. The `BACKPORT_CUSTOMIZATIONS` environment variable (file path).
|
|
392
|
+
* 3. `customizations.yaml` in the current working directory.
|
|
393
|
+
*
|
|
394
|
+
* The resolved value is parsed with `js-yaml` when it comes from a string/URL,
|
|
395
|
+
* then validated against `CustomizationsSchema` via Zod.
|
|
396
|
+
*/
|
|
397
|
+
/**
|
|
398
|
+
* Read, parse, and validate the customizations manifest.
|
|
399
|
+
*
|
|
400
|
+
* @param source - Optional source: a file path, an HTTP(S) URL, or an inline object.
|
|
401
|
+
* Falls back to `BACKPORT_CUSTOMIZATIONS` env var, then `./customizations.yaml`.
|
|
402
|
+
* @returns A fully validated `Customizations` object.
|
|
403
|
+
* @throws {Error} If the file/URL cannot be read.
|
|
404
|
+
* @throws {ZodError} If the structure does not satisfy `CustomizationsSchema`.
|
|
405
|
+
*/
|
|
406
|
+
async function loadCustomizations(source) {
|
|
407
|
+
if (source !== void 0 && typeof source === "object") return CustomizationsSchema.parse(source);
|
|
408
|
+
const strSource = source ?? process.env.BACKPORT_CUSTOMIZATIONS ?? resolve(process.cwd(), "customizations.yaml");
|
|
409
|
+
let raw;
|
|
410
|
+
if (typeof strSource === "string" && (strSource.startsWith("http://") || strSource.startsWith("https://"))) {
|
|
411
|
+
const response = await fetch(strSource);
|
|
412
|
+
if (!response.ok) throw new Error(`Failed to fetch customizations from ${strSource}: HTTP ${response.status} ${response.statusText}`);
|
|
413
|
+
const text = await response.text();
|
|
414
|
+
raw = yaml.load(text);
|
|
415
|
+
} else raw = yaml.load(readFileSync(strSource, "utf-8"));
|
|
416
|
+
return CustomizationsSchema.parse(raw);
|
|
417
|
+
}
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/tool-helper.ts
|
|
420
|
+
/**
|
|
421
|
+
* @file tool-helper.ts
|
|
422
|
+
*
|
|
423
|
+
* Typed wrapper around `createTool` from `@sctg/cline-sdk`.
|
|
424
|
+
*
|
|
425
|
+
* **Problem — overload resolution ambiguity:**
|
|
426
|
+
* `@sctg/cline-shared/dist/tools/create.d.ts` declares two overloads of
|
|
427
|
+
* `createTool`:
|
|
428
|
+
* 1. `createTool(config: { inputSchema: Record<string, unknown>, ... })`
|
|
429
|
+
* 2. `createTool<TSchema extends ZodTypeAny, TOutput>(config: { inputSchema: TSchema, ... })`
|
|
430
|
+
*
|
|
431
|
+
* TypeScript evaluates overloads in declaration order. Because `ZodObject`
|
|
432
|
+
* is structurally assignable to `Record<string, unknown>`, overload 1 always
|
|
433
|
+
* wins, and the inferred input type in `execute` becomes `unknown` instead of
|
|
434
|
+
* the typed schema inference from overload 2.
|
|
435
|
+
*
|
|
436
|
+
* **Solution:**
|
|
437
|
+
* `defineTool` has the correct generic signature (overload 2's types) and casts
|
|
438
|
+
* the config to `any` before forwarding to `createTool`. TypeScript then
|
|
439
|
+
* infers the Zod-typed `execute` parameter correctly at every call site.
|
|
440
|
+
*
|
|
441
|
+
* This is the only place where `as any` is used in the codebase.
|
|
442
|
+
*/
|
|
443
|
+
/**
|
|
444
|
+
* Creates a fully-typed agent tool from the provided configuration.
|
|
445
|
+
*
|
|
446
|
+
* This is a thin wrapper around `createTool` that exists solely to fix TypeScript
|
|
447
|
+
* overload resolution. All arguments are forwarded unchanged; the only difference
|
|
448
|
+
* from calling `createTool` directly is that `TSchema` is correctly inferred from
|
|
449
|
+
* `inputSchema`.
|
|
450
|
+
*
|
|
451
|
+
* @typeParam TSchema - Zod schema type for the tool's input object.
|
|
452
|
+
* @typeParam TOutput - Return type of the `execute` function.
|
|
453
|
+
*
|
|
454
|
+
* @param config - Tool configuration object.
|
|
455
|
+
* @param config.name - Machine-readable tool name (snake_case by convention).
|
|
456
|
+
* @param config.description - Natural-language description shown to the LLM.
|
|
457
|
+
* @param config.inputSchema - Zod schema that validates and types the tool's input.
|
|
458
|
+
* @param config.execute - Async function called by the agent runtime. Receives
|
|
459
|
+
* a fully typed `input` (inferred from `TSchema`) and
|
|
460
|
+
* an `AgentToolContext` for runtime metadata.
|
|
461
|
+
* @param config.lifecycle - Optional lifecycle hooks (e.g. `completesRun: true`).
|
|
462
|
+
* @param config.timeoutMs - Optional per-invocation timeout in milliseconds.
|
|
463
|
+
* @param config.retryable - Whether the runtime should retry on transient failure.
|
|
464
|
+
* @param config.maxRetries - Maximum retry attempts (used when `retryable` is `true`).
|
|
465
|
+
* @returns A fully constructed `AgentTool` ready to be passed to the `Agent` constructor.
|
|
466
|
+
*/
|
|
467
|
+
function defineTool(config) {
|
|
468
|
+
return createTool(config);
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/git/git-client.ts
|
|
472
|
+
/**
|
|
473
|
+
* @file git/git-client.ts
|
|
474
|
+
*
|
|
475
|
+
* Low-level git operations used by the backport agent.
|
|
476
|
+
*
|
|
477
|
+
* Design principles:
|
|
478
|
+
* - **No shell interpolation** — all git invocations use `execFileSync` with an
|
|
479
|
+
* explicit argument array. User-supplied strings (SHAs, branch names, file
|
|
480
|
+
* paths) are always passed as separate array items, never concatenated into a
|
|
481
|
+
* shell command string. This prevents command-injection vulnerabilities.
|
|
482
|
+
* - **Synchronous I/O** — the agent runs a single-threaded, sequential workflow;
|
|
483
|
+
* async/await overhead would add complexity without benefit.
|
|
484
|
+
* - **Minimal surface** — each function does exactly one git operation. Higher-
|
|
485
|
+
* level orchestration lives in `git-tools.ts` (agent tool wrappers).
|
|
486
|
+
*/
|
|
487
|
+
/**
|
|
488
|
+
* Executes a git command in the given working directory using `execFileSync`.
|
|
489
|
+
*
|
|
490
|
+
* All standard streams are piped so that stdout and stderr are captured rather
|
|
491
|
+
* than printed to the terminal. The return value is the trimmed stdout string.
|
|
492
|
+
*
|
|
493
|
+
* **Security note:** arguments must always be provided as an array — never as a
|
|
494
|
+
* pre-joined string — to prevent shell injection.
|
|
495
|
+
*
|
|
496
|
+
* @param args - Git sub-command and its arguments, e.g. `["cherry", "-v", "HEAD"]`.
|
|
497
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
498
|
+
* @returns Trimmed stdout output of the git command.
|
|
499
|
+
* @throws If the git process exits with a non-zero status (e.g. merge conflict).
|
|
500
|
+
*/
|
|
501
|
+
function git(args, cwd) {
|
|
502
|
+
return execFileSync("git", args, {
|
|
503
|
+
cwd,
|
|
504
|
+
encoding: "utf-8",
|
|
505
|
+
stdio: [
|
|
506
|
+
"pipe",
|
|
507
|
+
"pipe",
|
|
508
|
+
"pipe"
|
|
509
|
+
]
|
|
510
|
+
}).trim();
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Ensures the local git history is deep enough to compute the merge-base between
|
|
514
|
+
* the upstream branch and the fork branch.
|
|
515
|
+
*
|
|
516
|
+
* Many CI environments start with a shallow clone (`--depth=1`). This function
|
|
517
|
+
* progressively deepens the clone in steps rather than fetching the entire
|
|
518
|
+
* history at once, which keeps network usage low for the common case.
|
|
519
|
+
*
|
|
520
|
+
* Algorithm:
|
|
521
|
+
* 1. Try `git merge-base` at the current depth.
|
|
522
|
+
* 2. If it fails, deepen by the next step value and retry.
|
|
523
|
+
* 3. If even `maxDepth` is insufficient, fall back to `--unshallow`.
|
|
524
|
+
*
|
|
525
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
526
|
+
* @param upstreamRef - Full ref for the upstream branch, e.g. `"upstream/main"`.
|
|
527
|
+
* @param forkRef - Full ref for the fork branch, e.g. `"origin/main"`.
|
|
528
|
+
* @param maxDepth - Depth ceiling before attempting a full unshallow fetch.
|
|
529
|
+
* Defaults to 4000 (matches `sync.maxFetchDepth` default).
|
|
530
|
+
* @returns The SHA of the common ancestor commit (the merge-base).
|
|
531
|
+
* @throws If the merge-base cannot be determined even after a full fetch.
|
|
532
|
+
*/
|
|
533
|
+
function ensureMergeBase(cwd, upstreamRef, forkRef, maxDepth = 4e3) {
|
|
534
|
+
const depths = [
|
|
535
|
+
200,
|
|
536
|
+
500,
|
|
537
|
+
1e3,
|
|
538
|
+
2e3,
|
|
539
|
+
maxDepth
|
|
540
|
+
];
|
|
541
|
+
for (const depth of depths) try {
|
|
542
|
+
return git([
|
|
543
|
+
"merge-base",
|
|
544
|
+
upstreamRef,
|
|
545
|
+
forkRef
|
|
546
|
+
], cwd);
|
|
547
|
+
} catch {
|
|
548
|
+
if (depth === maxDepth) {
|
|
549
|
+
git(["fetch", "--unshallow"], cwd);
|
|
550
|
+
return git([
|
|
551
|
+
"merge-base",
|
|
552
|
+
upstreamRef,
|
|
553
|
+
forkRef
|
|
554
|
+
], cwd);
|
|
555
|
+
}
|
|
556
|
+
git(["fetch", `--deepen=${depth}`], cwd);
|
|
557
|
+
}
|
|
558
|
+
throw new Error("Could not find merge-base even after full fetch");
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Lists all upstream commits that are not yet present in the fork branch.
|
|
562
|
+
*
|
|
563
|
+
* Uses `git cherry` which compares patch content (not just SHA) so that commits
|
|
564
|
+
* that were already cherry-picked (and may have a different SHA in the fork) are
|
|
565
|
+
* correctly identified as already applied.
|
|
566
|
+
*
|
|
567
|
+
* Output format of `git cherry -v <upstream> <fork>`:
|
|
568
|
+
* - Lines prefixed with `+` are **not** in the fork → candidates for cherry-pick.
|
|
569
|
+
* - Lines prefixed with `-` **are** equivalent in the fork → already applied.
|
|
570
|
+
*
|
|
571
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
572
|
+
* @param upstreamRef - Full ref for the upstream branch, e.g. `"upstream/main"`.
|
|
573
|
+
* @param forkRef - Full ref for the fork branch, e.g. `"origin/main"`.
|
|
574
|
+
* @returns Array of `CandidateCommit` objects, oldest-first.
|
|
575
|
+
*/
|
|
576
|
+
function listCandidateCommits(cwd, upstreamRef, forkRef) {
|
|
577
|
+
const cherryOutput = git([
|
|
578
|
+
"cherry",
|
|
579
|
+
"-v",
|
|
580
|
+
forkRef,
|
|
581
|
+
upstreamRef
|
|
582
|
+
], cwd);
|
|
583
|
+
if (!cherryOutput) return [];
|
|
584
|
+
return cherryOutput.split("\n").map((line) => {
|
|
585
|
+
const marker = line[0];
|
|
586
|
+
const rest = line.slice(2);
|
|
587
|
+
const spaceIdx = rest.indexOf(" ");
|
|
588
|
+
return {
|
|
589
|
+
sha: rest.slice(0, spaceIdx),
|
|
590
|
+
subject: rest.slice(spaceIdx + 1),
|
|
591
|
+
alreadyApplied: marker === "-"
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Returns the list of file paths changed by a single commit.
|
|
597
|
+
*
|
|
598
|
+
* Internally calls `git diff-tree --no-commit-id -r --name-only <sha>` which
|
|
599
|
+
* lists only changed paths without any diff content, keeping the output small.
|
|
600
|
+
*
|
|
601
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
602
|
+
* @param sha - Full or abbreviated commit SHA.
|
|
603
|
+
* @returns Array of repository-relative file paths changed by the commit.
|
|
604
|
+
* Empty array if the commit has no file changes (e.g. an empty commit).
|
|
605
|
+
*/
|
|
606
|
+
function getCommitChangedFiles(cwd, sha) {
|
|
607
|
+
const output = git([
|
|
608
|
+
"diff-tree",
|
|
609
|
+
"--no-commit-id",
|
|
610
|
+
"-r",
|
|
611
|
+
"--name-only",
|
|
612
|
+
sha
|
|
613
|
+
], cwd);
|
|
614
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Returns the full diff of a single commit, capped to `maxBytes` characters.
|
|
618
|
+
*
|
|
619
|
+
* The diff includes the commit stat summary (`--stat`) followed by the patch
|
|
620
|
+
* (`--patch`). Large diffs are truncated with a notice so that the LLM context
|
|
621
|
+
* window is not exhausted by a single commit.
|
|
622
|
+
*
|
|
623
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
624
|
+
* @param sha - Full or abbreviated commit SHA.
|
|
625
|
+
* @param maxBytes - Maximum number of characters to return. Defaults to 32 000.
|
|
626
|
+
* @returns Diff string, possibly truncated.
|
|
627
|
+
*/
|
|
628
|
+
function getCommitDiff(cwd, sha, maxBytes = 32e3) {
|
|
629
|
+
const full = git([
|
|
630
|
+
"show",
|
|
631
|
+
"--stat",
|
|
632
|
+
"--patch",
|
|
633
|
+
sha
|
|
634
|
+
], cwd);
|
|
635
|
+
return full.length > maxBytes ? full.slice(0, maxBytes) + "\n... [truncated]" : full;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Creates a new sync branch from the tip of the fork branch.
|
|
639
|
+
*
|
|
640
|
+
* The branch is created locally; `pushBranch` must be called separately to
|
|
641
|
+
* publish it to the remote.
|
|
642
|
+
*
|
|
643
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
644
|
+
* @param branchName - Name for the new sync branch.
|
|
645
|
+
* @param forkRef - Full ref of the fork branch to branch off, e.g. `"origin/main"`.
|
|
646
|
+
*/
|
|
647
|
+
function createSyncBranch(cwd, branchName, forkRef) {
|
|
648
|
+
git(["checkout", forkRef], cwd);
|
|
649
|
+
git([
|
|
650
|
+
"checkout",
|
|
651
|
+
"-b",
|
|
652
|
+
branchName
|
|
653
|
+
], cwd);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Attempts to cherry-pick a single upstream commit onto the current branch.
|
|
657
|
+
*
|
|
658
|
+
* The `-x` flag appends `(cherry picked from commit …)` to the commit message,
|
|
659
|
+
* providing an audit trail in the fork's history.
|
|
660
|
+
*
|
|
661
|
+
* On conflict, the cherry-pick is intentionally left **in progress** rather than
|
|
662
|
+
* aborted. This allows the agent to inspect each conflicted file via
|
|
663
|
+
* `getConflictContext`, resolve them, then call `continueCherryPick`. If the
|
|
664
|
+
* agent cannot resolve the conflicts, it should call `abortCherryPick` instead.
|
|
665
|
+
*
|
|
666
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
667
|
+
* @param sha - Full or abbreviated SHA of the commit to cherry-pick.
|
|
668
|
+
* @returns An object with `success: true` if the cherry-pick applied cleanly,
|
|
669
|
+
* or `success: false` plus the list of conflicted file paths.
|
|
670
|
+
*/
|
|
671
|
+
function cherryPick(cwd, sha) {
|
|
672
|
+
try {
|
|
673
|
+
git([
|
|
674
|
+
"cherry-pick",
|
|
675
|
+
"-x",
|
|
676
|
+
sha
|
|
677
|
+
], cwd);
|
|
678
|
+
return {
|
|
679
|
+
success: true,
|
|
680
|
+
conflictedFiles: []
|
|
681
|
+
};
|
|
682
|
+
} catch {
|
|
683
|
+
const status = git([
|
|
684
|
+
"diff",
|
|
685
|
+
"--name-only",
|
|
686
|
+
"--diff-filter=U"
|
|
687
|
+
], cwd);
|
|
688
|
+
return {
|
|
689
|
+
success: false,
|
|
690
|
+
conflictedFiles: status ? status.split("\n").filter(Boolean) : []
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Aborts a cherry-pick that is currently in progress.
|
|
696
|
+
*
|
|
697
|
+
* This resets the index and working tree to the state before `git cherry-pick`
|
|
698
|
+
* was called. It is safe to call even if no cherry-pick is in progress (the
|
|
699
|
+
* error is swallowed silently).
|
|
700
|
+
*
|
|
701
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
702
|
+
*/
|
|
703
|
+
function abortCherryPick(cwd) {
|
|
704
|
+
try {
|
|
705
|
+
git(["cherry-pick", "--abort"], cwd);
|
|
706
|
+
} catch {}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Returns the content of a file at a specific git ref.
|
|
710
|
+
*
|
|
711
|
+
* Common use cases:
|
|
712
|
+
* - `ref = "HEAD"` → the fork's current version of the file.
|
|
713
|
+
* - `ref = "CHERRY_PICK_HEAD"` → the upstream version being cherry-picked.
|
|
714
|
+
* - `ref = "<sha>"` → the version at any specific commit.
|
|
715
|
+
*
|
|
716
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
717
|
+
* @param ref - Git ref, symbolic name, or SHA.
|
|
718
|
+
* @param filePath - Repository-relative path of the file, e.g. `"src/foo.ts"`.
|
|
719
|
+
* @returns The file content as a UTF-8 string, or `null` if the file does not
|
|
720
|
+
* exist at the given ref (e.g. the file was added by the cherry-picked commit).
|
|
721
|
+
*/
|
|
722
|
+
function getFileAtRef(cwd, ref, filePath) {
|
|
723
|
+
try {
|
|
724
|
+
return git(["show", `${ref}:${filePath}`], cwd);
|
|
725
|
+
} catch {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Writes resolved content to a file on disk and stages it with `git add`.
|
|
731
|
+
*
|
|
732
|
+
* Called by the agent after resolving each conflicted file. The file must
|
|
733
|
+
* contain no conflict markers before calling this function.
|
|
734
|
+
*
|
|
735
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
736
|
+
* @param filePath - Repository-relative path of the file, e.g. `"src/foo.ts"`.
|
|
737
|
+
* @param content - Fully resolved file content, free of conflict markers.
|
|
738
|
+
*/
|
|
739
|
+
function writeAndStageFile(cwd, filePath, content) {
|
|
740
|
+
writeFileSync(`${cwd}/${filePath}`, content, "utf-8");
|
|
741
|
+
git(["add", filePath], cwd);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Completes an in-progress cherry-pick after all conflicts have been resolved and staged.
|
|
745
|
+
*
|
|
746
|
+
* Uses `GIT_EDITOR=true` to suppress the interactive editor that git would
|
|
747
|
+
* otherwise open for the commit message, making this safe to call in a
|
|
748
|
+
* non-interactive CI environment.
|
|
749
|
+
*
|
|
750
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
751
|
+
* @throws If there are still unstaged conflicted files when this is called.
|
|
752
|
+
*/
|
|
753
|
+
function continueCherryPick(cwd) {
|
|
754
|
+
execFileSync("git", [
|
|
755
|
+
"cherry-pick",
|
|
756
|
+
"--continue",
|
|
757
|
+
"--no-edit"
|
|
758
|
+
], {
|
|
759
|
+
cwd,
|
|
760
|
+
encoding: "utf-8",
|
|
761
|
+
env: {
|
|
762
|
+
...process.env,
|
|
763
|
+
GIT_EDITOR: "true"
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Pushes the sync branch to the fork remote.
|
|
769
|
+
*
|
|
770
|
+
* A simple non-force push. If the branch already exists on the remote with
|
|
771
|
+
* different history the push will fail — the agent should never force-push to
|
|
772
|
+
* avoid overwriting human commits.
|
|
773
|
+
*
|
|
774
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
775
|
+
* @param remote - Name of the git remote to push to, e.g. `"origin"`.
|
|
776
|
+
* @param branchName - Name of the local branch to push.
|
|
777
|
+
*/
|
|
778
|
+
function pushBranch(cwd, remote, branchName) {
|
|
779
|
+
git([
|
|
780
|
+
"push",
|
|
781
|
+
remote,
|
|
782
|
+
branchName
|
|
783
|
+
], cwd);
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Fetches both the upstream and fork remotes to bring local refs up to date.
|
|
787
|
+
*
|
|
788
|
+
* Uses a shallow fetch (`--depth=N`) to keep network usage proportional. The
|
|
789
|
+
* depth here corresponds to `sync.initialFetchDepth`; `ensureMergeBase` will
|
|
790
|
+
* deepen further if needed.
|
|
791
|
+
*
|
|
792
|
+
* @param cwd - Absolute path to the repository working directory.
|
|
793
|
+
* @param upstreamRemote - Name of the upstream git remote, e.g. `"upstream"`.
|
|
794
|
+
* @param forkRemote - Name of the fork git remote, e.g. `"origin"`.
|
|
795
|
+
* @param depth - Shallow fetch depth.
|
|
796
|
+
*/
|
|
797
|
+
function fetchRemotes(cwd, upstreamRemote, forkRemote, depth) {
|
|
798
|
+
git([
|
|
799
|
+
"fetch",
|
|
800
|
+
`--depth=${depth}`,
|
|
801
|
+
upstreamRemote
|
|
802
|
+
], cwd);
|
|
803
|
+
git([
|
|
804
|
+
"fetch",
|
|
805
|
+
`--depth=${depth}`,
|
|
806
|
+
forkRemote
|
|
807
|
+
], cwd);
|
|
808
|
+
}
|
|
809
|
+
//#endregion
|
|
810
|
+
//#region src/git/git-tools.ts
|
|
811
|
+
/**
|
|
812
|
+
* @file git/git-tools.ts
|
|
813
|
+
*
|
|
814
|
+
* Factory that creates the agent tools wrapping all low-level git operations.
|
|
815
|
+
*
|
|
816
|
+
* Each tool returned by `makeGitTools` corresponds to a single capability that
|
|
817
|
+
* the LLM can invoke during the sync workflow:
|
|
818
|
+
*
|
|
819
|
+
* 1. `fetch_remotes` — update local refs from upstream and fork.
|
|
820
|
+
* 2. `list_candidate_commits` — discover which upstream commits to sync.
|
|
821
|
+
* 3. `get_commit_details` — inspect changed files and full diff.
|
|
822
|
+
* 4. `create_sync_branch` — branch off the fork tip for this sync run.
|
|
823
|
+
* 5. `cherry_pick_commit` — apply a single commit; reports conflicts.
|
|
824
|
+
* 6. `abort_cherry_pick` — abandon a conflicting cherry-pick.
|
|
825
|
+
* 7. `get_conflict_context` — fetch fork, upstream, and marker-annotated versions.
|
|
826
|
+
* 8. `apply_resolved_file` — write the LLM's resolution and stage it.
|
|
827
|
+
* 9. `continue_cherry_pick` — complete the cherry-pick after all files resolved.
|
|
828
|
+
* 10. `push_sync_branch` — publish the sync branch to the fork remote.
|
|
829
|
+
*
|
|
830
|
+
* All tools respect the `sync.dryRun` flag by returning early with a `dryRun:true`
|
|
831
|
+
* marker instead of performing any mutating operation.
|
|
832
|
+
*/
|
|
833
|
+
/**
|
|
834
|
+
* Tests whether a repo-relative file path matches any of the given patterns.
|
|
835
|
+
*
|
|
836
|
+
* Patterns are either:
|
|
837
|
+
* - A glob string (matched via `minimatch` with `matchBase: true`).
|
|
838
|
+
* - A regex literal in the form `/source/flags` (e.g. `"/^sdk\\/.*\.ts$/i"`).
|
|
839
|
+
*
|
|
840
|
+
* @param filePath - Repo-relative path of the file to test.
|
|
841
|
+
* @param patterns - Array of glob or regex patterns from the config.
|
|
842
|
+
* @returns `true` if the path matches at least one pattern.
|
|
843
|
+
*/
|
|
844
|
+
function matchesResolvePattern(filePath, patterns) {
|
|
845
|
+
for (const pattern of patterns) if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
|
|
846
|
+
const lastSlash = pattern.lastIndexOf("/");
|
|
847
|
+
const source = pattern.slice(1, lastSlash);
|
|
848
|
+
const flags = pattern.slice(lastSlash + 1);
|
|
849
|
+
try {
|
|
850
|
+
if (new RegExp(source, flags).test(filePath)) return true;
|
|
851
|
+
} catch {}
|
|
852
|
+
} else if (minimatch(filePath, pattern, { matchBase: true })) return true;
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Builds and returns all git-related agent tools pre-bound to the provided config.
|
|
857
|
+
*
|
|
858
|
+
* The returned array is spread directly into the `Agent` constructor's `tools`
|
|
859
|
+
* array. Each tool captures `workingDir`, `upstream`, `fork`, and `sync` from
|
|
860
|
+
* the config via closure, so callers never need to pass them per-invocation.
|
|
861
|
+
*
|
|
862
|
+
* @param config - Validated `SyncConfig` loaded from `config.json`.
|
|
863
|
+
* @returns Array of ten agent tools covering the full git workflow.
|
|
864
|
+
*/
|
|
865
|
+
function makeGitTools(config) {
|
|
866
|
+
const { workingDir, upstream, fork, sync } = config;
|
|
867
|
+
return [
|
|
868
|
+
defineTool({
|
|
869
|
+
name: "fetch_remotes",
|
|
870
|
+
description: "Fetch both upstream and fork remotes to ensure local refs are up to date.",
|
|
871
|
+
inputSchema: z.object({}),
|
|
872
|
+
execute: async () => {
|
|
873
|
+
fetchRemotes(workingDir, upstream.remote, fork.remote, sync.initialFetchDepth);
|
|
874
|
+
ensureMergeBase(workingDir, `${upstream.remote}/${upstream.branch}`, `${fork.remote}/${fork.branch}`, sync.maxFetchDepth);
|
|
875
|
+
return { success: true };
|
|
876
|
+
}
|
|
877
|
+
}),
|
|
878
|
+
defineTool({
|
|
879
|
+
name: "list_candidate_commits",
|
|
880
|
+
description: "List upstream commits that are not yet applied to the fork branch. Uses git cherry to detect already-applied patches by content, not just SHA. Returns an array of candidate commits with their SHA, subject, and alreadyApplied flag.",
|
|
881
|
+
inputSchema: z.object({}),
|
|
882
|
+
execute: async () => {
|
|
883
|
+
const pending = listCandidateCommits(workingDir, `${upstream.remote}/${upstream.branch}`, `${fork.remote}/${fork.branch}`).filter((c) => !c.alreadyApplied).slice(0, sync.maxCommitsPerRun);
|
|
884
|
+
return {
|
|
885
|
+
candidates: pending,
|
|
886
|
+
total: pending.length
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
}),
|
|
890
|
+
defineTool({
|
|
891
|
+
name: "get_commit_details",
|
|
892
|
+
description: "Get the changed files and diff for a specific upstream commit. Use this before classifying risk or attempting a cherry-pick.",
|
|
893
|
+
inputSchema: z.object({
|
|
894
|
+
sha: z.string().describe("The commit SHA to inspect"),
|
|
895
|
+
/** Set to false to skip the diff and only retrieve the file list. */
|
|
896
|
+
includeDiff: z.boolean().default(true)
|
|
897
|
+
}),
|
|
898
|
+
execute: async ({ sha, includeDiff }) => {
|
|
899
|
+
return {
|
|
900
|
+
sha,
|
|
901
|
+
changedFiles: getCommitChangedFiles(workingDir, sha),
|
|
902
|
+
diff: includeDiff ? getCommitDiff(workingDir, sha) : null
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
}),
|
|
906
|
+
defineTool({
|
|
907
|
+
name: "create_sync_branch",
|
|
908
|
+
description: "Create a new sync branch from the fork branch tip. The branch name is auto-generated with today's date. Returns the branch name.",
|
|
909
|
+
inputSchema: z.object({}),
|
|
910
|
+
execute: async () => {
|
|
911
|
+
if (sync.dryRun) return {
|
|
912
|
+
branchName: null,
|
|
913
|
+
dryRun: true
|
|
914
|
+
};
|
|
915
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
916
|
+
const branchName = `${sync.branchPrefix}${upstream.branch}-${date}`;
|
|
917
|
+
createSyncBranch(workingDir, branchName, `${fork.remote}/${fork.branch}`);
|
|
918
|
+
return { branchName };
|
|
919
|
+
}
|
|
920
|
+
}),
|
|
921
|
+
defineTool({
|
|
922
|
+
name: "cherry_pick_commit",
|
|
923
|
+
description: "Attempt to cherry-pick a single upstream commit onto the current sync branch. Returns success:true if clean, or success:false with conflictedFiles if conflicts arose. On conflict, the cherry-pick is left in progress for the resolve_conflict tool.",
|
|
924
|
+
inputSchema: z.object({ sha: z.string().describe("Upstream commit SHA to cherry-pick") }),
|
|
925
|
+
execute: async ({ sha }) => {
|
|
926
|
+
if (sync.dryRun) return {
|
|
927
|
+
success: true,
|
|
928
|
+
dryRun: true,
|
|
929
|
+
conflictedFiles: []
|
|
930
|
+
};
|
|
931
|
+
return cherryPick(workingDir, sha);
|
|
932
|
+
}
|
|
933
|
+
}),
|
|
934
|
+
defineTool({
|
|
935
|
+
name: "abort_cherry_pick",
|
|
936
|
+
description: "Abort the current cherry-pick in progress. Call this when a conflict cannot be resolved automatically.",
|
|
937
|
+
inputSchema: z.object({}),
|
|
938
|
+
execute: async () => {
|
|
939
|
+
abortCherryPick(workingDir);
|
|
940
|
+
return { aborted: true };
|
|
941
|
+
}
|
|
942
|
+
}),
|
|
943
|
+
defineTool({
|
|
944
|
+
name: "get_conflict_context",
|
|
945
|
+
description: "For a conflicted file, return the fork version (HEAD), the upstream version (CHERRY_PICK_HEAD), and the current file content with conflict markers. Use this to gather context before resolving.",
|
|
946
|
+
inputSchema: z.object({ filePath: z.string().describe("Repo-relative path of the conflicted file") }),
|
|
947
|
+
execute: async ({ filePath }) => {
|
|
948
|
+
const forkVersion = getFileAtRef(workingDir, "HEAD", filePath);
|
|
949
|
+
const upstreamVersion = getFileAtRef(workingDir, "CHERRY_PICK_HEAD", filePath);
|
|
950
|
+
let withMarkers = null;
|
|
951
|
+
try {
|
|
952
|
+
withMarkers = readFileSync(`${workingDir}/${filePath}`, "utf-8");
|
|
953
|
+
} catch {
|
|
954
|
+
withMarkers = null;
|
|
955
|
+
}
|
|
956
|
+
let forcedStrategy = null;
|
|
957
|
+
const resolveConfig = config.resolve;
|
|
958
|
+
if (resolveConfig) {
|
|
959
|
+
if (matchesResolvePattern(filePath, resolveConfig.theirs ?? [])) forcedStrategy = "theirs";
|
|
960
|
+
else if (matchesResolvePattern(filePath, resolveConfig.ours ?? [])) forcedStrategy = "ours";
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
filePath,
|
|
964
|
+
forkVersion,
|
|
965
|
+
upstreamVersion,
|
|
966
|
+
withMarkers,
|
|
967
|
+
forcedStrategy
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
}),
|
|
971
|
+
defineTool({
|
|
972
|
+
name: "apply_resolved_file",
|
|
973
|
+
description: "Write the resolved content for a conflicted file and stage it. Call this for each conflicted file before calling continue_cherry_pick.",
|
|
974
|
+
inputSchema: z.object({
|
|
975
|
+
filePath: z.string().describe("Repo-relative path of the file"),
|
|
976
|
+
resolvedContent: z.string().describe("The fully resolved file content, with no conflict markers")
|
|
977
|
+
}),
|
|
978
|
+
execute: async ({ filePath, resolvedContent }) => {
|
|
979
|
+
if (sync.dryRun) return {
|
|
980
|
+
staged: false,
|
|
981
|
+
dryRun: true
|
|
982
|
+
};
|
|
983
|
+
writeAndStageFile(workingDir, filePath, resolvedContent);
|
|
984
|
+
return {
|
|
985
|
+
staged: true,
|
|
986
|
+
filePath
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
}),
|
|
990
|
+
defineTool({
|
|
991
|
+
name: "continue_cherry_pick",
|
|
992
|
+
description: "Complete the cherry-pick after all conflicted files have been resolved and staged via apply_resolved_file.",
|
|
993
|
+
inputSchema: z.object({}),
|
|
994
|
+
execute: async () => {
|
|
995
|
+
if (sync.dryRun) return {
|
|
996
|
+
committed: false,
|
|
997
|
+
dryRun: true
|
|
998
|
+
};
|
|
999
|
+
continueCherryPick(workingDir);
|
|
1000
|
+
return { committed: true };
|
|
1001
|
+
}
|
|
1002
|
+
}),
|
|
1003
|
+
defineTool({
|
|
1004
|
+
name: "push_sync_branch",
|
|
1005
|
+
description: "Push the current sync branch to the fork remote.",
|
|
1006
|
+
inputSchema: z.object({
|
|
1007
|
+
/** Name of the local sync branch to push, as returned by `create_sync_branch`. */
|
|
1008
|
+
branchName: z.string() }),
|
|
1009
|
+
execute: async ({ branchName }) => {
|
|
1010
|
+
if (sync.dryRun) return {
|
|
1011
|
+
pushed: false,
|
|
1012
|
+
dryRun: true
|
|
1013
|
+
};
|
|
1014
|
+
pushBranch(workingDir, fork.remote, branchName);
|
|
1015
|
+
return {
|
|
1016
|
+
pushed: true,
|
|
1017
|
+
branchName
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
})
|
|
1021
|
+
];
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/git/git-init.ts
|
|
1025
|
+
/**
|
|
1026
|
+
* @file git/git-init.ts
|
|
1027
|
+
*
|
|
1028
|
+
* Repository initialisation and authentication helpers.
|
|
1029
|
+
*
|
|
1030
|
+
* Two exported functions:
|
|
1031
|
+
*
|
|
1032
|
+
* - `applyGitAuth(config)` — configures the process environment so that all
|
|
1033
|
+
* subsequent git calls (via `git-client.ts`) use the right credentials.
|
|
1034
|
+
* Supports SSH private keys (via `GIT_SSH_COMMAND`) and HTTP bearer tokens
|
|
1035
|
+
* (via git's `http.extraHeader` environment config).
|
|
1036
|
+
*
|
|
1037
|
+
* - `ensureWorkingDir(config)` — makes the `workingDir` ready for the agent:
|
|
1038
|
+
* · If the directory does not exist (or is not a git repo): clones `fork.url`.
|
|
1039
|
+
* · If it already exists: fetches all remotes to bring it up to date.
|
|
1040
|
+
* · Ensures the upstream remote is properly configured when its URL is provided
|
|
1041
|
+
* and it differs from the fork remote.
|
|
1042
|
+
*
|
|
1043
|
+
* Design notes:
|
|
1044
|
+
* - All git I/O is synchronous (matches the rest of git-client.ts).
|
|
1045
|
+
* - No secrets are stored in child-process arguments — tokens are injected via
|
|
1046
|
+
* environment variables only.
|
|
1047
|
+
* - `fork.url` supports any git hosting provider (GitHub, GitLab, Gitea, …).
|
|
1048
|
+
*/
|
|
1049
|
+
/**
|
|
1050
|
+
* Resolves a config value that may reference an environment variable.
|
|
1051
|
+
*
|
|
1052
|
+
* If `value` starts with `$` the remainder is treated as an environment variable
|
|
1053
|
+
* name. This lets operators write `"$GITHUB_TOKEN"` in config.json instead of
|
|
1054
|
+
* embedding the raw secret, keeping credentials out of version control.
|
|
1055
|
+
*
|
|
1056
|
+
* @param value - Raw config string, e.g. `"ghp_abc123"` or `"$MY_TOKEN"`.
|
|
1057
|
+
* @returns The resolved string, or `undefined` if the env var is not set.
|
|
1058
|
+
*/
|
|
1059
|
+
function resolveConfigValue(value) {
|
|
1060
|
+
if (value.startsWith("$")) return process.env[value.slice(1)];
|
|
1061
|
+
return value;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Configures process-level environment variables so that all subsequent git
|
|
1065
|
+
* operations (in this process and its child processes) use the right credentials.
|
|
1066
|
+
*
|
|
1067
|
+
* Priority order:
|
|
1068
|
+
* 1. SSH key (`config.auth.sshKeyPath`) → sets `GIT_SSH_COMMAND`
|
|
1069
|
+
* 2. Token (`config.auth.githubToken`) → sets `GIT_CONFIG_*` http.extraHeader
|
|
1070
|
+
* 3. Fallback to `GITHUB_TOKEN` env var → same as (2)
|
|
1071
|
+
*
|
|
1072
|
+
* If none of the above are set the function is a no-op; git will use whatever
|
|
1073
|
+
* credentials are already available in the environment (SSH agent, credential helper…).
|
|
1074
|
+
*
|
|
1075
|
+
* @param config - Validated `SyncConfig` loaded from `config.json`.
|
|
1076
|
+
*/
|
|
1077
|
+
function applyGitAuth(config) {
|
|
1078
|
+
const { sshKeyPath, githubToken } = config.auth;
|
|
1079
|
+
if (sshKeyPath) {
|
|
1080
|
+
const keyPath = sshKeyPath.replace(/^~(?=\/|$)/, process.env.HOME ?? "");
|
|
1081
|
+
process.env.GIT_SSH_COMMAND = `ssh -i "${keyPath}" -o StrictHostKeyChecking=no -o BatchMode=yes`;
|
|
1082
|
+
process.stderr.write(`[GitAuth] SSH key configured: ${keyPath}\n`);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const token = resolveConfigValue(githubToken ?? "$GITHUB_TOKEN");
|
|
1086
|
+
if (token) {
|
|
1087
|
+
const count = parseInt(process.env.GIT_CONFIG_COUNT ?? "0", 10);
|
|
1088
|
+
process.env.GIT_CONFIG_COUNT = String(count + 1);
|
|
1089
|
+
process.env[`GIT_CONFIG_KEY_${count}`] = "http.extraHeader";
|
|
1090
|
+
process.env[`GIT_CONFIG_VALUE_${count}`] = `Authorization: Bearer ${token}`;
|
|
1091
|
+
process.stderr.write("[GitAuth] HTTP bearer token auth configured.\n");
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Ensures that `config.workingDir` contains a ready-to-use git repository.
|
|
1096
|
+
*
|
|
1097
|
+
* **Clone** (directory absent or not a git repo):
|
|
1098
|
+
* Clones `config.fork.url` into `config.workingDir`. The parent directory is
|
|
1099
|
+
* created if necessary. Throws if `fork.url` is not set.
|
|
1100
|
+
*
|
|
1101
|
+
* **Sync** (directory is already a git repo):
|
|
1102
|
+
* Runs `git fetch --all --prune` to bring all tracked remotes up to date.
|
|
1103
|
+
*
|
|
1104
|
+
* **Upstream remote**:
|
|
1105
|
+
* When `config.upstream.url` is set and `upstream.remote` differs from
|
|
1106
|
+
* `fork.remote`, the upstream remote is added (or its URL updated) automatically.
|
|
1107
|
+
*
|
|
1108
|
+
* @param config - Validated `SyncConfig` loaded from `config.json`.
|
|
1109
|
+
* @throws If cloning is required but `fork.url` is not configured.
|
|
1110
|
+
*/
|
|
1111
|
+
function ensureWorkingDir(config) {
|
|
1112
|
+
const { workingDir, upstream, fork } = config;
|
|
1113
|
+
if (!existsSync(`${workingDir}/.git`)) {
|
|
1114
|
+
if (!fork.url) throw new Error(`[GitInit] '${workingDir}' is not a git repository and fork.url is not configured. Set fork.url to a valid git URL (SSH or HTTPS) to enable automatic cloning.`);
|
|
1115
|
+
mkdirSync(dirname(workingDir), { recursive: true });
|
|
1116
|
+
process.stderr.write(`[GitInit] Cloning ${fork.url} → ${workingDir} ...\n`);
|
|
1117
|
+
execFileSync("git", [
|
|
1118
|
+
"clone",
|
|
1119
|
+
fork.url,
|
|
1120
|
+
workingDir
|
|
1121
|
+
], { stdio: [
|
|
1122
|
+
"pipe",
|
|
1123
|
+
"inherit",
|
|
1124
|
+
"inherit"
|
|
1125
|
+
] });
|
|
1126
|
+
process.stderr.write("[GitInit] Clone complete.\n");
|
|
1127
|
+
} else {
|
|
1128
|
+
process.stderr.write(`[GitInit] ${workingDir} found — fetching all remotes...\n`);
|
|
1129
|
+
try {
|
|
1130
|
+
git([
|
|
1131
|
+
"fetch",
|
|
1132
|
+
"--all",
|
|
1133
|
+
"--prune"
|
|
1134
|
+
], workingDir);
|
|
1135
|
+
process.stderr.write("[GitInit] Fetch complete.\n");
|
|
1136
|
+
} catch (err) {
|
|
1137
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1138
|
+
process.stderr.write(`[GitInit] Warning: fetch failed: ${msg}\n`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (upstream.url && upstream.remote !== fork.remote) try {
|
|
1142
|
+
if (!git(["remote"], workingDir).split("\n").filter(Boolean).includes(upstream.remote)) {
|
|
1143
|
+
git([
|
|
1144
|
+
"remote",
|
|
1145
|
+
"add",
|
|
1146
|
+
upstream.remote,
|
|
1147
|
+
upstream.url
|
|
1148
|
+
], workingDir);
|
|
1149
|
+
process.stderr.write(`[GitInit] Added remote '${upstream.remote}' → ${upstream.url}\n`);
|
|
1150
|
+
} else if (git([
|
|
1151
|
+
"remote",
|
|
1152
|
+
"get-url",
|
|
1153
|
+
upstream.remote
|
|
1154
|
+
], workingDir) !== upstream.url) {
|
|
1155
|
+
git([
|
|
1156
|
+
"remote",
|
|
1157
|
+
"set-url",
|
|
1158
|
+
upstream.remote,
|
|
1159
|
+
upstream.url
|
|
1160
|
+
], workingDir);
|
|
1161
|
+
process.stderr.write(`[GitInit] Updated remote '${upstream.remote}' → ${upstream.url}\n`);
|
|
1162
|
+
}
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1165
|
+
process.stderr.write(`[GitInit] Warning: could not configure upstream remote: ${msg}\n`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/risk/classify-risk.ts
|
|
1170
|
+
/**
|
|
1171
|
+
* @file risk/classify-risk.ts
|
|
1172
|
+
*
|
|
1173
|
+
* Purely deterministic commit risk classifier — no LLM is involved.
|
|
1174
|
+
*
|
|
1175
|
+
* The classifier assigns one of three risk levels to an upstream commit:
|
|
1176
|
+
* - **low** — Only touches files that are safe to auto-apply.
|
|
1177
|
+
* - **medium** — Touches shared infrastructure (API layer, shared types, services)
|
|
1178
|
+
* that may interact with fork customizations.
|
|
1179
|
+
* - **high** — Touches build configuration, CI pipelines, lockfiles, protobuf
|
|
1180
|
+
* definitions, or a file explicitly listed in the fork's
|
|
1181
|
+
* `customizations.yaml`.
|
|
1182
|
+
*
|
|
1183
|
+
* The LLM agent uses this output as high-level context before deciding whether
|
|
1184
|
+
* to attempt a cherry-pick, skip, or request human review.
|
|
1185
|
+
*
|
|
1186
|
+
* Pattern matching uses `minimatch` (the same glob library used by `.gitignore`
|
|
1187
|
+
* and the VS Code extension tree). All patterns are relative to the repository
|
|
1188
|
+
* root, as returned by `git diff-tree --name-only`.
|
|
1189
|
+
*/
|
|
1190
|
+
/**
|
|
1191
|
+
* Glob patterns whose matches unconditionally elevate risk to `"high"`.
|
|
1192
|
+
*
|
|
1193
|
+
* These files are considered build-critical or structurally sensitive:
|
|
1194
|
+
* - Dependency manifests and lockfiles (`package.json`, `*-lock.*`, `*.lock`)
|
|
1195
|
+
* - CI / GitHub Actions workflows (`.github/workflows/**`)
|
|
1196
|
+
* - Build scripts directory (`scripts/**`)
|
|
1197
|
+
* - ESBuild config files (`esbuild.*`)
|
|
1198
|
+
* - TypeScript project references (`tsconfig*.json`)
|
|
1199
|
+
* - Protobuf definitions (`proto/**`) — changes here require proto regeneration
|
|
1200
|
+
* and affect the entire RPC layer
|
|
1201
|
+
*/
|
|
1202
|
+
var HIGH_RISK_PATTERNS = [
|
|
1203
|
+
"package.json",
|
|
1204
|
+
"package-lock.json",
|
|
1205
|
+
"pnpm-lock.yaml",
|
|
1206
|
+
"yarn.lock",
|
|
1207
|
+
".github/workflows/**",
|
|
1208
|
+
"scripts/**",
|
|
1209
|
+
"esbuild.*",
|
|
1210
|
+
"tsconfig*.json",
|
|
1211
|
+
"proto/**"
|
|
1212
|
+
];
|
|
1213
|
+
/**
|
|
1214
|
+
* Glob patterns whose matches elevate risk to `"medium"` when no high-risk
|
|
1215
|
+
* pattern has already been matched.
|
|
1216
|
+
*
|
|
1217
|
+
* These paths contain shared infrastructure that is more likely to conflict
|
|
1218
|
+
* with fork customizations than generic feature code:
|
|
1219
|
+
* - `src/core/api/**` — API provider implementations
|
|
1220
|
+
* - `src/shared/**` — Shared types and utilities used everywhere
|
|
1221
|
+
* - `src/services/**` — Backend services (MCP hub, etc.)
|
|
1222
|
+
* - `webview-ui/src/services/**`— Webview service layer
|
|
1223
|
+
*/
|
|
1224
|
+
var MEDIUM_RISK_PATTERNS = [
|
|
1225
|
+
"src/core/api/**",
|
|
1226
|
+
"src/shared/**",
|
|
1227
|
+
"src/services/**",
|
|
1228
|
+
"webview-ui/src/services/**"
|
|
1229
|
+
];
|
|
1230
|
+
/**
|
|
1231
|
+
* Classifies the risk level of an upstream commit based on which files it changes.
|
|
1232
|
+
*
|
|
1233
|
+
* Evaluation order (highest-priority first):
|
|
1234
|
+
* 1. Fork customization zones (`customizations.yaml`) → always `high`.
|
|
1235
|
+
* 2. `HIGH_RISK_PATTERNS` glob matches → always `high`.
|
|
1236
|
+
* 3. `MEDIUM_RISK_PATTERNS` glob matches → `medium` (if not already high).
|
|
1237
|
+
* 4. Detected file deletions or renames → elevate to at least `medium`.
|
|
1238
|
+
* 5. No matches → remains `low`.
|
|
1239
|
+
*
|
|
1240
|
+
* This function is **pure and deterministic** — given the same inputs it always
|
|
1241
|
+
* returns the same output. It has no side effects and performs no I/O.
|
|
1242
|
+
*
|
|
1243
|
+
* @param sha - Full or abbreviated commit SHA (used to label the result).
|
|
1244
|
+
* @param changedFiles - Repository-relative file paths changed by the commit,
|
|
1245
|
+
* as returned by `getCommitChangedFiles`.
|
|
1246
|
+
* @param customizations - Validated customizations manifest from `loadCustomizations`.
|
|
1247
|
+
* @returns A `CommitRisk` record with the computed level, reasons, and customization matches.
|
|
1248
|
+
*/
|
|
1249
|
+
function classifyRisk(sha, changedFiles, customizations) {
|
|
1250
|
+
const reasons = [];
|
|
1251
|
+
const matchedCustomizationIds = [];
|
|
1252
|
+
let level = "low";
|
|
1253
|
+
for (const entry of customizations.customizations) {
|
|
1254
|
+
const hits = changedFiles.filter((f) => entry.paths.some((p) => minimatch(f, p)));
|
|
1255
|
+
if (hits.length > 0) {
|
|
1256
|
+
matchedCustomizationIds.push(entry.id);
|
|
1257
|
+
reasons.push(`Touches customization "${entry.id}": ${hits.join(", ")}`);
|
|
1258
|
+
level = "high";
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
for (const pattern of HIGH_RISK_PATTERNS) {
|
|
1262
|
+
const hits = changedFiles.filter((f) => minimatch(f, pattern));
|
|
1263
|
+
if (hits.length > 0) {
|
|
1264
|
+
if (level !== "high") level = "high";
|
|
1265
|
+
reasons.push(`High-risk file pattern "${pattern}": ${hits.join(", ")}`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
if (level === "low") for (const pattern of MEDIUM_RISK_PATTERNS) {
|
|
1269
|
+
const hits = changedFiles.filter((f) => minimatch(f, pattern));
|
|
1270
|
+
if (hits.length > 0) {
|
|
1271
|
+
level = "medium";
|
|
1272
|
+
reasons.push(`Medium-risk pattern "${pattern}": ${hits.join(", ")}`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (changedFiles.filter((f) => f.startsWith("DELETE:") || f.startsWith("RENAME:")).length > 0) {
|
|
1276
|
+
if (level === "low") level = "medium";
|
|
1277
|
+
reasons.push(`File deletions or renames detected`);
|
|
1278
|
+
}
|
|
1279
|
+
if (reasons.length === 0) reasons.push("No risk patterns matched — appears to be a low-risk change");
|
|
1280
|
+
return {
|
|
1281
|
+
sha,
|
|
1282
|
+
level,
|
|
1283
|
+
reasons,
|
|
1284
|
+
touchesCustomization: matchedCustomizationIds.length > 0,
|
|
1285
|
+
customizationIds: matchedCustomizationIds
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/risk/risk-tools.ts
|
|
1290
|
+
/**
|
|
1291
|
+
* @file risk/risk-tools.ts
|
|
1292
|
+
*
|
|
1293
|
+
* Factory that creates the `classify_commit_risk` agent tool.
|
|
1294
|
+
*
|
|
1295
|
+
* Risk classification is a deterministic gate that runs **before** any LLM
|
|
1296
|
+
* reasoning: the agent calls this tool first, learns the risk level and
|
|
1297
|
+
* affected customizations, then decides how to proceed (auto-apply, apply with
|
|
1298
|
+
* validation, or escalate to human review).
|
|
1299
|
+
*
|
|
1300
|
+
* The tool is kept in a separate factory function so that the validated
|
|
1301
|
+
* `customizations` object (loaded once at startup) can be captured by closure
|
|
1302
|
+
* and reused across every invocation without re-parsing the YAML file.
|
|
1303
|
+
*/
|
|
1304
|
+
/**
|
|
1305
|
+
* Builds and returns the `classify_commit_risk` agent tool.
|
|
1306
|
+
*
|
|
1307
|
+
* The tool is pre-bound to `config` and `customizations` so that callers only
|
|
1308
|
+
* need to provide the commit SHA at invocation time.
|
|
1309
|
+
*
|
|
1310
|
+
* @param config - Validated `SyncConfig` (provides `workingDir`).
|
|
1311
|
+
* @param customizations - Validated customizations manifest (provides zone definitions).
|
|
1312
|
+
* @returns A single agent tool: `classify_commit_risk`.
|
|
1313
|
+
*/
|
|
1314
|
+
function makeRiskTool(config, customizations) {
|
|
1315
|
+
return defineTool({
|
|
1316
|
+
name: "classify_commit_risk",
|
|
1317
|
+
description: "Classify the risk level of an upstream commit by analysing which files it changes. Returns 'low', 'medium', or 'high' with human-readable reasons. High risk means the commit touches fork customization zones or build-critical files. This is a deterministic check — no LLM is used here.",
|
|
1318
|
+
inputSchema: z.object({
|
|
1319
|
+
/** Full or abbreviated SHA of the upstream commit to classify. */
|
|
1320
|
+
sha: z.string().describe("Upstream commit SHA to classify") }),
|
|
1321
|
+
execute: async ({ sha }) => {
|
|
1322
|
+
return classifyRisk(sha, getCommitChangedFiles(config.workingDir, sha), customizations);
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region src/validation/commands.ts
|
|
1328
|
+
/**
|
|
1329
|
+
* @file validation/commands.ts
|
|
1330
|
+
*
|
|
1331
|
+
* Allowlist-based command runner for the validation suite.
|
|
1332
|
+
*
|
|
1333
|
+
* **Security model:**
|
|
1334
|
+
* The LLM agent may suggest commands to run during validation. To prevent
|
|
1335
|
+
* arbitrary code execution, every command is checked against a fixed prefix
|
|
1336
|
+
* allowlist before being executed. Only standard package-manager test scripts
|
|
1337
|
+
* and well-known CLI tools are permitted. The allowlist is hard-coded in this
|
|
1338
|
+
* file and cannot be extended by the agent at runtime.
|
|
1339
|
+
*
|
|
1340
|
+
* **No shell interpolation:**
|
|
1341
|
+
* Commands are split on whitespace and executed via `execFileSync(bin, args[])`
|
|
1342
|
+
* (not through a shell). This prevents shell metacharacter injection even for
|
|
1343
|
+
* allowlisted commands.
|
|
1344
|
+
*
|
|
1345
|
+
* **Failure fast:**
|
|
1346
|
+
* `runValidationSuite` stops at the first failing command so that the agent
|
|
1347
|
+
* receives a clear, actionable error rather than a wall of cascading output.
|
|
1348
|
+
*/
|
|
1349
|
+
/**
|
|
1350
|
+
* Hard-coded allowlist of command prefixes that the agent is permitted to execute.
|
|
1351
|
+
*
|
|
1352
|
+
* A command is allowed if and only if it starts with one of these strings.
|
|
1353
|
+
* The list is intentionally conservative:
|
|
1354
|
+
* - `"npm run "` — npm scripts defined in package.json
|
|
1355
|
+
* - `"pnpm run "` — pnpm equivalent
|
|
1356
|
+
* - `"yarn run "` — yarn equivalent
|
|
1357
|
+
* - `"npx tsc"` — TypeScript type-checker
|
|
1358
|
+
* - `"npx eslint"` — ESLint linter
|
|
1359
|
+
* - `"npx vitest"` — Vitest unit test runner
|
|
1360
|
+
* - `"node --version"` — Non-destructive version probe (useful for diagnostics)
|
|
1361
|
+
*/
|
|
1362
|
+
var ALLOWED_COMMAND_PREFIXES = [
|
|
1363
|
+
"npm run ",
|
|
1364
|
+
"pnpm run ",
|
|
1365
|
+
"yarn run ",
|
|
1366
|
+
"npx tsc",
|
|
1367
|
+
"npx eslint",
|
|
1368
|
+
"npx vitest",
|
|
1369
|
+
"node --version"
|
|
1370
|
+
];
|
|
1371
|
+
/**
|
|
1372
|
+
* Returns `true` if the command starts with an allowlisted prefix.
|
|
1373
|
+
*
|
|
1374
|
+
* The check is intentionally a simple `startsWith` — it does not parse the
|
|
1375
|
+
* full command. This makes the allowlist easy to audit.
|
|
1376
|
+
*
|
|
1377
|
+
* @param command - The full command string to check.
|
|
1378
|
+
* @returns `true` if the command is allowed, `false` if it should be blocked.
|
|
1379
|
+
*/
|
|
1380
|
+
function isAllowedCommand(command) {
|
|
1381
|
+
return ALLOWED_COMMAND_PREFIXES.some((prefix) => command.startsWith(prefix));
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Runs a single validation command and returns its result.
|
|
1385
|
+
*
|
|
1386
|
+
* If the command is not on the allowlist, it is immediately blocked and a
|
|
1387
|
+
* descriptive error is returned without executing anything.
|
|
1388
|
+
*
|
|
1389
|
+
* Execution details:
|
|
1390
|
+
* - `stdio: ["pipe","pipe","pipe"]` — all streams are captured, not printed.
|
|
1391
|
+
* - `timeout: 120_000` ms — commands are killed if they take more than 2 minutes.
|
|
1392
|
+
* - No `shell: true` — the command is parsed by whitespace split and executed directly.
|
|
1393
|
+
*
|
|
1394
|
+
* @param command - Full command string, e.g. `"npm run typecheck"`.
|
|
1395
|
+
* @param cwd - Absolute working directory in which to run the command.
|
|
1396
|
+
* @returns A `CommandResult` with the success flag, exit code, and captured output.
|
|
1397
|
+
*/
|
|
1398
|
+
function runValidationCommand(command, cwd) {
|
|
1399
|
+
if (!isAllowedCommand(command)) return {
|
|
1400
|
+
command,
|
|
1401
|
+
success: false,
|
|
1402
|
+
exitCode: 1,
|
|
1403
|
+
output: `Blocked: command "${command}" is not in the allowed list`
|
|
1404
|
+
};
|
|
1405
|
+
const parts = command.split(" ");
|
|
1406
|
+
const bin = parts[0];
|
|
1407
|
+
const args = parts.slice(1);
|
|
1408
|
+
try {
|
|
1409
|
+
return {
|
|
1410
|
+
command,
|
|
1411
|
+
success: true,
|
|
1412
|
+
exitCode: 0,
|
|
1413
|
+
output: execFileSync(bin, args, {
|
|
1414
|
+
cwd,
|
|
1415
|
+
encoding: "utf-8",
|
|
1416
|
+
stdio: [
|
|
1417
|
+
"pipe",
|
|
1418
|
+
"pipe",
|
|
1419
|
+
"pipe"
|
|
1420
|
+
],
|
|
1421
|
+
timeout: 12e4
|
|
1422
|
+
})
|
|
1423
|
+
};
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
const e = err;
|
|
1426
|
+
const output = [
|
|
1427
|
+
e.stdout,
|
|
1428
|
+
e.stderr,
|
|
1429
|
+
e.message
|
|
1430
|
+
].filter(Boolean).join("\n");
|
|
1431
|
+
return {
|
|
1432
|
+
command,
|
|
1433
|
+
success: false,
|
|
1434
|
+
exitCode: e.status ?? 1,
|
|
1435
|
+
output
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Runs an ordered list of validation commands, stopping on the first failure.
|
|
1441
|
+
*
|
|
1442
|
+
* "Fail fast" semantics are intentional: the agent should see the first broken
|
|
1443
|
+
* command and address it rather than drowning in cascading failures.
|
|
1444
|
+
*
|
|
1445
|
+
* @param commands - Ordered array of command strings to execute.
|
|
1446
|
+
* @param cwd - Absolute working directory for all commands.
|
|
1447
|
+
* @returns Array of `CommandResult` objects, one per executed command.
|
|
1448
|
+
* If a command fails, no subsequent commands are executed.
|
|
1449
|
+
*/
|
|
1450
|
+
function runValidationSuite(commands, cwd) {
|
|
1451
|
+
const results = [];
|
|
1452
|
+
for (const command of commands) {
|
|
1453
|
+
const result = runValidationCommand(command, cwd);
|
|
1454
|
+
results.push(result);
|
|
1455
|
+
if (!result.success) break;
|
|
1456
|
+
}
|
|
1457
|
+
return results;
|
|
1458
|
+
}
|
|
1459
|
+
//#endregion
|
|
1460
|
+
//#region src/validation/validation-tools.ts
|
|
1461
|
+
/**
|
|
1462
|
+
* @file validation/validation-tools.ts
|
|
1463
|
+
*
|
|
1464
|
+
* Factory that creates the `run_validation` agent tool.
|
|
1465
|
+
*
|
|
1466
|
+
* Validation is the agent's safety net: before accepting a cherry-picked commit
|
|
1467
|
+
* as "done", the agent runs the suite appropriate for the commit's risk level to
|
|
1468
|
+
* confirm that the fork still builds and passes tests.
|
|
1469
|
+
*
|
|
1470
|
+
* Risk → suite mapping (configured in `config.validation`):
|
|
1471
|
+
* - `"low"` → `config.validation.low` (e.g. only typecheck)
|
|
1472
|
+
* - `"medium"` → `config.validation.medium` (e.g. typecheck + unit tests)
|
|
1473
|
+
* - `"high"` → `config.validation.high` (e.g. full build + integration tests)
|
|
1474
|
+
*
|
|
1475
|
+
* The agent may append extra customization-specific commands via the
|
|
1476
|
+
* `extraCommands` input field. All commands (base suite + extras) are passed
|
|
1477
|
+
* through the same `ALLOWED_COMMAND_PREFIXES` allowlist in `commands.ts`.
|
|
1478
|
+
*/
|
|
1479
|
+
/**
|
|
1480
|
+
* Builds and returns the `run_validation` agent tool.
|
|
1481
|
+
*
|
|
1482
|
+
* The tool is pre-bound to `config` so that the caller only needs to supply the
|
|
1483
|
+
* risk level and any optional extra commands at invocation time.
|
|
1484
|
+
*
|
|
1485
|
+
* @param config - Validated `SyncConfig` (provides `workingDir` and `validation` suites).
|
|
1486
|
+
* @returns A single agent tool: `run_validation`.
|
|
1487
|
+
*/
|
|
1488
|
+
function makeValidationTool(config) {
|
|
1489
|
+
return defineTool({
|
|
1490
|
+
name: "run_validation",
|
|
1491
|
+
description: "Run the validation suite appropriate for a given risk level. 'low' runs only typecheck. 'medium' adds unit tests. 'high' adds build and integration tests. All commands are allowlisted — arbitrary commands are rejected. Returns success status and per-command output.",
|
|
1492
|
+
inputSchema: z.object({
|
|
1493
|
+
/** Risk level computed by `classify_commit_risk`. Determines which suite to run. */
|
|
1494
|
+
riskLevel: z.enum([
|
|
1495
|
+
"low",
|
|
1496
|
+
"medium",
|
|
1497
|
+
"high"
|
|
1498
|
+
]).describe("Risk level determines which suite to run"),
|
|
1499
|
+
/**
|
|
1500
|
+
* Optional additional commands to append to the standard suite.
|
|
1501
|
+
* Useful for customization-specific verification commands listed in
|
|
1502
|
+
* `customizations.yaml` under `testCommands`.
|
|
1503
|
+
* Each command must still match the `ALLOWED_COMMAND_PREFIXES` allowlist.
|
|
1504
|
+
*/
|
|
1505
|
+
extraCommands: z.array(z.string()).optional().describe("Additional commands to append, must match the allowed prefix list")
|
|
1506
|
+
}),
|
|
1507
|
+
execute: async ({ riskLevel, extraCommands = [] }) => {
|
|
1508
|
+
if (config.sync.dryRun) return {
|
|
1509
|
+
dryRun: true,
|
|
1510
|
+
results: [],
|
|
1511
|
+
allPassed: true
|
|
1512
|
+
};
|
|
1513
|
+
const results = runValidationSuite([...{
|
|
1514
|
+
low: config.validation.low,
|
|
1515
|
+
medium: config.validation.medium,
|
|
1516
|
+
high: config.validation.high
|
|
1517
|
+
}[riskLevel], ...extraCommands], config.workingDir);
|
|
1518
|
+
return {
|
|
1519
|
+
riskLevel,
|
|
1520
|
+
results,
|
|
1521
|
+
allPassed: results.every((r) => r.success)
|
|
1522
|
+
};
|
|
1523
|
+
},
|
|
1524
|
+
timeoutMs: 3e5
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
//#endregion
|
|
1528
|
+
//#region src/github/github-tools.ts
|
|
1529
|
+
/**
|
|
1530
|
+
* @file github/github-tools.ts
|
|
1531
|
+
*
|
|
1532
|
+
* GitHub API tools that allow the agent to manage pull requests on the fork
|
|
1533
|
+
* repository. All operations go through the official `@octokit/rest` client
|
|
1534
|
+
* which enforces HTTPS and authenticated requests.
|
|
1535
|
+
*
|
|
1536
|
+
* The three tools returned by `makeGitHubTools` cover the PR lifecycle:
|
|
1537
|
+
* 1. `find_existing_sync_pr` — check whether a previous run already opened a PR,
|
|
1538
|
+
* and if so, recover its machine-readable state so the current run can resume.
|
|
1539
|
+
* 2. `create_sync_pr` — open a new draft PR with the sync branch, embedding
|
|
1540
|
+
* both human-readable Markdown and a hidden machine-readable state block.
|
|
1541
|
+
* 3. `add_human_review_comment` — flag specific files or decisions for a human
|
|
1542
|
+
* reviewer by posting a comment on the PR.
|
|
1543
|
+
*
|
|
1544
|
+
* **Idempotency** is achieved via `STATE_MARKER_START/END` HTML comment markers
|
|
1545
|
+
* embedded inside the PR body. On each run the agent first calls
|
|
1546
|
+
* `find_existing_sync_pr`, which extracts and parses the JSON state block if
|
|
1547
|
+
* present, allowing the run to skip already-processed commits.
|
|
1548
|
+
*
|
|
1549
|
+
* **Authentication** is read from the `GITHUB_TOKEN` environment variable at
|
|
1550
|
+
* tool invocation time (not at module load time) so that the token is never
|
|
1551
|
+
* stored in process memory longer than necessary.
|
|
1552
|
+
*/
|
|
1553
|
+
/**
|
|
1554
|
+
* Creates and returns an authenticated Octokit instance using the `GITHUB_TOKEN`
|
|
1555
|
+
* environment variable.
|
|
1556
|
+
*
|
|
1557
|
+
* Called inside each tool's `execute` function rather than once at module level
|
|
1558
|
+
* to keep the token out of long-lived closures.
|
|
1559
|
+
*
|
|
1560
|
+
* @returns Authenticated `Octokit` REST client.
|
|
1561
|
+
* @throws If `GITHUB_TOKEN` is not set in the environment.
|
|
1562
|
+
*/
|
|
1563
|
+
function makeOctokit() {
|
|
1564
|
+
const token = process.env.GITHUB_TOKEN;
|
|
1565
|
+
if (!token) throw new Error("GITHUB_TOKEN environment variable is required");
|
|
1566
|
+
return new Octokit({ auth: token });
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Parses an `"owner/repo"` string into its component parts.
|
|
1570
|
+
*
|
|
1571
|
+
* @param repoStr - Repository string in `"owner/repo"` format.
|
|
1572
|
+
* @returns An object with `owner` and `repo` string fields.
|
|
1573
|
+
* @throws If the string does not contain exactly one `/` separator.
|
|
1574
|
+
*/
|
|
1575
|
+
function parseRepo(repoStr) {
|
|
1576
|
+
const [owner, repo] = repoStr.split("/");
|
|
1577
|
+
if (!owner || !repo) throw new Error(`Invalid repo format: "${repoStr}", expected "owner/repo"`);
|
|
1578
|
+
return {
|
|
1579
|
+
owner,
|
|
1580
|
+
repo
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Opening delimiter of the hidden JSON state block embedded in the PR body.
|
|
1585
|
+
*
|
|
1586
|
+
* The state block is wrapped in an HTML comment so it is invisible in the
|
|
1587
|
+
* rendered PR view but can be extracted programmatically on re-runs.
|
|
1588
|
+
* Example embedded block:
|
|
1589
|
+
* ```
|
|
1590
|
+
* <!-- backport-agent-state
|
|
1591
|
+
* { "processedShas": ["abc123", "def456"] }
|
|
1592
|
+
* -->
|
|
1593
|
+
* ```
|
|
1594
|
+
*/
|
|
1595
|
+
var STATE_MARKER_START = "<!-- backport-agent-state\n";
|
|
1596
|
+
/**
|
|
1597
|
+
* Closing delimiter of the hidden JSON state block embedded in the PR body.
|
|
1598
|
+
* @see STATE_MARKER_START
|
|
1599
|
+
*/
|
|
1600
|
+
var STATE_MARKER_END = "\n-->";
|
|
1601
|
+
/**
|
|
1602
|
+
* Builds and returns the three GitHub API agent tools.
|
|
1603
|
+
*
|
|
1604
|
+
* All tools capture `fork`, `upstream`, and `sync` from the config via closure.
|
|
1605
|
+
* In dry-run mode, every tool returns early with `{ dryRun: true }` and performs
|
|
1606
|
+
* no network requests.
|
|
1607
|
+
*
|
|
1608
|
+
* @param config - Validated `SyncConfig` loaded from `config.json`.
|
|
1609
|
+
* @returns Array of three agent tools: `[findExistingPrTool, createSyncPrTool, addHumanReviewCommentTool]`.
|
|
1610
|
+
*/
|
|
1611
|
+
function makeGitHubTools(config) {
|
|
1612
|
+
const { fork, upstream, sync } = config;
|
|
1613
|
+
return [
|
|
1614
|
+
defineTool({
|
|
1615
|
+
name: "find_existing_sync_pr",
|
|
1616
|
+
description: "Search for an existing open sync PR created by the backport agent. Returns the PR number and current state JSON if found, null otherwise.",
|
|
1617
|
+
inputSchema: z.object({}),
|
|
1618
|
+
execute: async () => {
|
|
1619
|
+
if (sync.dryRun) return {
|
|
1620
|
+
pr: null,
|
|
1621
|
+
dryRun: true
|
|
1622
|
+
};
|
|
1623
|
+
const octokit = makeOctokit();
|
|
1624
|
+
const { owner, repo } = parseRepo(fork.repo);
|
|
1625
|
+
const { data: prs } = await octokit.pulls.list({
|
|
1626
|
+
owner,
|
|
1627
|
+
repo,
|
|
1628
|
+
state: "open",
|
|
1629
|
+
head: `${owner}:${sync.branchPrefix}`,
|
|
1630
|
+
per_page: 10
|
|
1631
|
+
});
|
|
1632
|
+
const agentPr = prs.find((pr) => pr.title.startsWith("Sync upstream") && pr.body?.includes(STATE_MARKER_START));
|
|
1633
|
+
if (!agentPr) return { pr: null };
|
|
1634
|
+
let agentState = null;
|
|
1635
|
+
if (agentPr.body) {
|
|
1636
|
+
const start = agentPr.body.indexOf(STATE_MARKER_START);
|
|
1637
|
+
const end = agentPr.body.indexOf(STATE_MARKER_END, start);
|
|
1638
|
+
if (start !== -1 && end !== -1) try {
|
|
1639
|
+
agentState = JSON.parse(agentPr.body.slice(start + 26, end));
|
|
1640
|
+
} catch {
|
|
1641
|
+
agentState = null;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return { pr: {
|
|
1645
|
+
number: agentPr.number,
|
|
1646
|
+
url: agentPr.html_url,
|
|
1647
|
+
state: agentState
|
|
1648
|
+
} };
|
|
1649
|
+
}
|
|
1650
|
+
}),
|
|
1651
|
+
defineTool({
|
|
1652
|
+
name: "create_sync_pr",
|
|
1653
|
+
description: "Create a draft pull request on the fork repository with the sync branch. Embeds a hidden state block for idempotent re-runs. Returns the PR URL.",
|
|
1654
|
+
inputSchema: z.object({
|
|
1655
|
+
/** Name of the local/remote sync branch created by `create_sync_branch`. */
|
|
1656
|
+
branchName: z.string(),
|
|
1657
|
+
/** Human-readable Markdown body shown in the GitHub PR UI. */
|
|
1658
|
+
markdownBody: z.string().describe("Human-readable PR body in Markdown"),
|
|
1659
|
+
/** Machine-readable JSON state to embed as a hidden comment for re-run idempotency. */
|
|
1660
|
+
agentState: z.record(z.string(), z.unknown()).describe("Machine-readable state to embed in the PR body"),
|
|
1661
|
+
/** Labels to apply to the PR. Defaults to `["sync", "agent-generated"]`. */
|
|
1662
|
+
labels: z.array(z.string()).default(["sync", "agent-generated"])
|
|
1663
|
+
}),
|
|
1664
|
+
execute: async ({ branchName, markdownBody, agentState, labels }) => {
|
|
1665
|
+
if (sync.dryRun) return {
|
|
1666
|
+
url: null,
|
|
1667
|
+
dryRun: true
|
|
1668
|
+
};
|
|
1669
|
+
const octokit = makeOctokit();
|
|
1670
|
+
const { owner, repo } = parseRepo(fork.repo);
|
|
1671
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1672
|
+
const body = `${markdownBody}\n\n${`${STATE_MARKER_START}${JSON.stringify(agentState, null, 2)}${STATE_MARKER_END}`}`;
|
|
1673
|
+
const { data: pr } = await octokit.pulls.create({
|
|
1674
|
+
owner,
|
|
1675
|
+
repo,
|
|
1676
|
+
title: `Sync upstream ${upstream.branch} into ${fork.branch} (${date})`,
|
|
1677
|
+
body,
|
|
1678
|
+
head: branchName,
|
|
1679
|
+
base: fork.branch,
|
|
1680
|
+
draft: true
|
|
1681
|
+
});
|
|
1682
|
+
try {
|
|
1683
|
+
await octokit.issues.addLabels({
|
|
1684
|
+
owner,
|
|
1685
|
+
repo,
|
|
1686
|
+
issue_number: pr.number,
|
|
1687
|
+
labels
|
|
1688
|
+
});
|
|
1689
|
+
} catch {}
|
|
1690
|
+
return {
|
|
1691
|
+
url: pr.html_url,
|
|
1692
|
+
number: pr.number
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
}),
|
|
1696
|
+
defineTool({
|
|
1697
|
+
name: "add_human_review_comment",
|
|
1698
|
+
description: "Add a comment to the sync PR flagging a specific file or decision for human review. Use when the agent cannot safely resolve a conflict automatically.",
|
|
1699
|
+
inputSchema: z.object({
|
|
1700
|
+
/** PR number on the fork repository to comment on. */
|
|
1701
|
+
prNumber: z.number().int(),
|
|
1702
|
+
/** Markdown-formatted comment body explaining what needs human attention. */
|
|
1703
|
+
comment: z.string().describe("Markdown comment explaining what needs human attention")
|
|
1704
|
+
}),
|
|
1705
|
+
execute: async ({ prNumber, comment }) => {
|
|
1706
|
+
if (sync.dryRun) return {
|
|
1707
|
+
commented: false,
|
|
1708
|
+
dryRun: true
|
|
1709
|
+
};
|
|
1710
|
+
const octokit = makeOctokit();
|
|
1711
|
+
const { owner, repo } = parseRepo(fork.repo);
|
|
1712
|
+
await octokit.issues.createComment({
|
|
1713
|
+
owner,
|
|
1714
|
+
repo,
|
|
1715
|
+
issue_number: prNumber,
|
|
1716
|
+
body: comment
|
|
1717
|
+
});
|
|
1718
|
+
return { commented: true };
|
|
1719
|
+
}
|
|
1720
|
+
})
|
|
1721
|
+
];
|
|
1722
|
+
}
|
|
1723
|
+
//#endregion
|
|
1724
|
+
//#region src/reports/report-tools.ts
|
|
1725
|
+
/**
|
|
1726
|
+
* @file reports/report-tools.ts
|
|
1727
|
+
*
|
|
1728
|
+
* Factory that creates the `generate_report` agent tool.
|
|
1729
|
+
*
|
|
1730
|
+
* The report tool is the **terminal step** of every sync run: the agent calls it
|
|
1731
|
+
* after processing all candidate commits. Setting `lifecycle: { completesRun: true }`
|
|
1732
|
+
* signals to the `@sctg/cline-sdk` runtime that the agent should stop after this
|
|
1733
|
+
* tool returns, so the tool doubles as both report generator and run terminator.
|
|
1734
|
+
*
|
|
1735
|
+
* The tool produces:
|
|
1736
|
+
* - A human-readable **Markdown string** suitable for use as a GitHub PR body.
|
|
1737
|
+
* - A compact **agentState** object that can be embedded in the PR body (hidden
|
|
1738
|
+
* HTML comment) for idempotent re-runs (see `github-tools.ts`).
|
|
1739
|
+
* - Boolean flags `allPassed` and `needsHumanReview` for caller logic.
|
|
1740
|
+
* - A detailed **Markdown file** written to `config.report.destination` combining
|
|
1741
|
+
* the PR-body summary, a Mermaid workflow diagram generated by the fast model,
|
|
1742
|
+
* and a full transcript of every AI sub-agent call from the prompts JSONL log.
|
|
1743
|
+
*
|
|
1744
|
+
* Report sections (PR body):
|
|
1745
|
+
* 1. Header — date, upstream ref, fork ref, sync branch name.
|
|
1746
|
+
* 2. Summary — counts of applied / needs-review / blocked commits.
|
|
1747
|
+
* 3. Applied commits — listed with risk badges and conflict-resolution notes.
|
|
1748
|
+
* 4. Human review required — conflicted or validation-failed commits with reasons.
|
|
1749
|
+
* 5. Blocked commits — SHAs that were not attempted at all.
|
|
1750
|
+
* 6. Agent decision log — ordered audit trail of key agent decisions.
|
|
1751
|
+
*
|
|
1752
|
+
* Additional sections (detailed file only):
|
|
1753
|
+
* 7. Mermaid workflow diagram — generated by the fast LLM from the run summary.
|
|
1754
|
+
* 8. AI sub-agent call log — full prompt/response transcript from the JSONL log.
|
|
1755
|
+
*/
|
|
1756
|
+
/**
|
|
1757
|
+
* Reads the JSONL prompt log, returning all valid entries.
|
|
1758
|
+
* Silently skips malformed lines so a corrupt log cannot block the report.
|
|
1759
|
+
*/
|
|
1760
|
+
function readPromptLog(logPath) {
|
|
1761
|
+
if (!existsSync(logPath)) return [];
|
|
1762
|
+
try {
|
|
1763
|
+
return readFileSync(logPath, "utf8").split("\n").filter(Boolean).flatMap((line) => {
|
|
1764
|
+
try {
|
|
1765
|
+
return [JSON.parse(line)];
|
|
1766
|
+
} catch {
|
|
1767
|
+
return [];
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
} catch {
|
|
1771
|
+
return [];
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Instantiates a minimal sub-Agent with no tools for a single reasoning turn.
|
|
1776
|
+
* Identical in purpose to the helper in `ai-tools.ts` but local to avoid a
|
|
1777
|
+
* cross-module import cycle.
|
|
1778
|
+
*/
|
|
1779
|
+
function makeReportSubAgent(modelId, systemPrompt) {
|
|
1780
|
+
return new Agent({
|
|
1781
|
+
providerId: "keypoollive",
|
|
1782
|
+
modelId,
|
|
1783
|
+
apiKey: "auto",
|
|
1784
|
+
systemPrompt,
|
|
1785
|
+
tools: []
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Calls the fast model to produce a Mermaid flowchart summarising the agent run.
|
|
1790
|
+
* Returns a fenced mermaid code block string, or a fallback placeholder on error.
|
|
1791
|
+
*/
|
|
1792
|
+
async function generateMermaidDiagram(config, runSummary) {
|
|
1793
|
+
const systemPrompt = `You are a technical diagram generator. When given a summary of a Git backport agent run, \
|
|
1794
|
+
produce a single Mermaid flowchart (flowchart TD) that visually represents: \
|
|
1795
|
+
(1) each candidate commit as a node labelled with its short SHA and subject, \
|
|
1796
|
+
(2) the risk level applied to each commit (low / medium / high), \
|
|
1797
|
+
(3) which AI tools were invoked (analyze_commit_for_backport, check_customization_compatibility, resolve_conflict_with_ai), \
|
|
1798
|
+
(4) the final disposition of each commit (applied ✅, blocked ⛔, needs-review ⚠️, skipped ⟳). \
|
|
1799
|
+
Use colour-coded styles: applied=green, blocked=red, needs-review=orange, skipped=grey. \
|
|
1800
|
+
Output ONLY the raw Mermaid code, no prose, no code fence.`;
|
|
1801
|
+
const userPrompt = `Agent run summary:\n\n${runSummary}\n\nOutput the Mermaid flowchart now.`;
|
|
1802
|
+
try {
|
|
1803
|
+
return "```mermaid\n" + ((await makeReportSubAgent(config.models.fast, systemPrompt).run(userPrompt)).outputText ?? "").trim().replace(/^```(?:mermaid)?\n?/i, "").replace(/\n?```$/, "").trim() + "\n```";
|
|
1804
|
+
} catch (err) {
|
|
1805
|
+
return `<!-- Mermaid generation failed: ${err instanceof Error ? err.message : String(err)} -->`;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Zod schema for the result of processing a single upstream commit.
|
|
1810
|
+
*
|
|
1811
|
+
* The agent populates one `CommitResult` per candidate commit processed during
|
|
1812
|
+
* the run. These are aggregated by the report tool to produce the final summary.
|
|
1813
|
+
*/
|
|
1814
|
+
var CommitResultSchema = z.object({
|
|
1815
|
+
/** Full SHA of the upstream commit. */
|
|
1816
|
+
sha: z.string(),
|
|
1817
|
+
/** Commit subject line (first line of the message). */
|
|
1818
|
+
subject: z.string(),
|
|
1819
|
+
/** Risk level assigned by `classify_commit_risk`. */
|
|
1820
|
+
riskLevel: z.enum([
|
|
1821
|
+
"low",
|
|
1822
|
+
"medium",
|
|
1823
|
+
"high"
|
|
1824
|
+
]),
|
|
1825
|
+
/**
|
|
1826
|
+
* Final disposition of this commit:
|
|
1827
|
+
* - `"applied"` — cherry-picked cleanly with no conflicts.
|
|
1828
|
+
* - `"skipped"` — already applied in the fork (git cherry found equivalent patch).
|
|
1829
|
+
* - `"conflict-resolved"` — had conflicts that the agent resolved automatically.
|
|
1830
|
+
* - `"conflict-blocked"` — had conflicts the agent could not safely resolve; needs human review.
|
|
1831
|
+
* - `"validation-failed"` — cherry-picked cleanly but the validation suite failed.
|
|
1832
|
+
*/
|
|
1833
|
+
status: z.enum([
|
|
1834
|
+
"applied",
|
|
1835
|
+
"skipped",
|
|
1836
|
+
"conflict-resolved",
|
|
1837
|
+
"conflict-blocked",
|
|
1838
|
+
"validation-failed"
|
|
1839
|
+
]),
|
|
1840
|
+
/** Paths of files that had merge conflicts (populated for conflict-* statuses). */
|
|
1841
|
+
conflictedFiles: z.array(z.string()).optional(),
|
|
1842
|
+
/** Human-readable reasons why this commit needs manual review. */
|
|
1843
|
+
humanReviewReasons: z.array(z.string()).optional(),
|
|
1844
|
+
/** Per-command validation results, populated when `status === "validation-failed"`. */
|
|
1845
|
+
validationResults: z.array(z.object({
|
|
1846
|
+
command: z.string(),
|
|
1847
|
+
success: z.boolean(),
|
|
1848
|
+
output: z.string()
|
|
1849
|
+
})).optional()
|
|
1850
|
+
});
|
|
1851
|
+
/**
|
|
1852
|
+
* Builds and returns the `generate_report` agent tool.
|
|
1853
|
+
*
|
|
1854
|
+
* @param config - Validated `SyncConfig` — used for model IDs and `report.destination`.
|
|
1855
|
+
* @param promptLogPath - Absolute path to the JSONL file written by `logPrompt()` in ai-tools.ts.
|
|
1856
|
+
* @returns A single agent tool: `generate_report` (with `completesRun: true`).
|
|
1857
|
+
*/
|
|
1858
|
+
function makeReportTool(config, promptLogPath) {
|
|
1859
|
+
return defineTool({
|
|
1860
|
+
name: "generate_report",
|
|
1861
|
+
description: "Generate the final sync report as a Markdown string suitable for a PR body. Call this as the LAST step after all commits have been processed. Returns the report text AND signals that the agent run is complete.",
|
|
1862
|
+
inputSchema: z.object({
|
|
1863
|
+
/** Name of the sync branch, or `null` in dry-run mode. */
|
|
1864
|
+
syncBranch: z.string().nullable(),
|
|
1865
|
+
/** Full ref of the upstream branch, e.g. `"upstream/main"`. */
|
|
1866
|
+
upstreamRef: z.string(),
|
|
1867
|
+
/** Full ref of the fork branch, e.g. `"origin/main"`. */
|
|
1868
|
+
forkRef: z.string(),
|
|
1869
|
+
/** Array of per-commit results — one entry per processed candidate. */
|
|
1870
|
+
commitResults: z.array(CommitResultSchema),
|
|
1871
|
+
/** Commits that were not attempted at all, with mandatory reasons. */
|
|
1872
|
+
blockedCommits: z.array(z.object({
|
|
1873
|
+
/** Full or abbreviated SHA of the blocked commit. */
|
|
1874
|
+
sha: z.string(),
|
|
1875
|
+
/** Human-readable reason why this commit was not attempted. */
|
|
1876
|
+
reason: z.string().describe("Why this commit was not attempted (AI analysis result, policy, etc.)"),
|
|
1877
|
+
/** Risk level from classify_commit_risk, if known. */
|
|
1878
|
+
riskLevel: z.enum([
|
|
1879
|
+
"low",
|
|
1880
|
+
"medium",
|
|
1881
|
+
"high"
|
|
1882
|
+
]).optional()
|
|
1883
|
+
})).describe("Commits not attempted, each with a specific reason"),
|
|
1884
|
+
/**
|
|
1885
|
+
* All SHAs returned by list_candidate_commits — used to detect silently dropped commits.
|
|
1886
|
+
* Pass the complete list so the report can cross-check accountability.
|
|
1887
|
+
*/
|
|
1888
|
+
allCandidateShas: z.array(z.string()).optional().describe("Complete list of SHAs from list_candidate_commits, used to detect unaccounted commits"),
|
|
1889
|
+
/** Ordered list of key decisions the agent made during this run, for audit purposes. */
|
|
1890
|
+
agentDecisions: z.array(z.string()).describe("Audit trail of key decisions made during this run")
|
|
1891
|
+
}),
|
|
1892
|
+
lifecycle: { completesRun: true },
|
|
1893
|
+
execute: async ({ syncBranch, upstreamRef, forkRef, commitResults, blockedCommits, agentDecisions, allCandidateShas }) => {
|
|
1894
|
+
const date = (/* @__PURE__ */ new Date()).toISOString();
|
|
1895
|
+
const timestampSlug = date.replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
|
1896
|
+
const applied = commitResults.filter((r) => ["applied", "conflict-resolved"].includes(r.status));
|
|
1897
|
+
const needsReview = commitResults.filter((r) => ["conflict-blocked", "validation-failed"].includes(r.status));
|
|
1898
|
+
const allPassed = needsReview.length === 0;
|
|
1899
|
+
const processedShas = new Set([...commitResults.map((r) => r.sha), ...blockedCommits.map((c) => c.sha)]);
|
|
1900
|
+
const unaccounted = (allCandidateShas ?? []).filter((sha) => !processedShas.has(sha) && !processedShas.has(sha.slice(0, 8)));
|
|
1901
|
+
const prBodyLines = [
|
|
1902
|
+
"## Backport Agent — Sync Report",
|
|
1903
|
+
"",
|
|
1904
|
+
`**Date**: ${date}`,
|
|
1905
|
+
`**Upstream ref**: \`${upstreamRef}\``,
|
|
1906
|
+
`**Fork ref**: \`${forkRef}\``,
|
|
1907
|
+
`**Sync branch**: ${syncBranch ? `\`${syncBranch}\`` : "_dry-run (no branch created)_"}`,
|
|
1908
|
+
"",
|
|
1909
|
+
"### Summary",
|
|
1910
|
+
"",
|
|
1911
|
+
`- ✅ Applied: ${applied.length}`,
|
|
1912
|
+
`- ⚠️ Needs human review: ${needsReview.length}`,
|
|
1913
|
+
`- ⛔ Blocked (not attempted): ${blockedCommits.length}`,
|
|
1914
|
+
...unaccounted.length > 0 ? [`- 🔴 Unaccounted (agent bug): ${unaccounted.length}`] : [],
|
|
1915
|
+
""
|
|
1916
|
+
];
|
|
1917
|
+
if (applied.length > 0) {
|
|
1918
|
+
prBodyLines.push("### Applied commits", "");
|
|
1919
|
+
for (const r of applied) {
|
|
1920
|
+
const badge = r.status === "conflict-resolved" ? " _(conflict resolved by agent)_" : "";
|
|
1921
|
+
prBodyLines.push(`- \`${r.sha.slice(0, 8)}\` [${r.riskLevel}] ${r.subject}${badge}`);
|
|
1922
|
+
}
|
|
1923
|
+
prBodyLines.push("");
|
|
1924
|
+
}
|
|
1925
|
+
if (needsReview.length > 0) {
|
|
1926
|
+
prBodyLines.push("### ⚠️ Human review required", "");
|
|
1927
|
+
for (const r of needsReview) {
|
|
1928
|
+
prBodyLines.push(`- \`${r.sha.slice(0, 8)}\` ${r.subject}`);
|
|
1929
|
+
if (r.conflictedFiles?.length) prBodyLines.push(` - Conflicted files: ${r.conflictedFiles.join(", ")}`);
|
|
1930
|
+
if (r.humanReviewReasons?.length) for (const reason of r.humanReviewReasons) prBodyLines.push(` - ${reason}`);
|
|
1931
|
+
}
|
|
1932
|
+
prBodyLines.push("");
|
|
1933
|
+
}
|
|
1934
|
+
if (blockedCommits.length > 0) {
|
|
1935
|
+
prBodyLines.push("### Blocked commits (not attempted)", "");
|
|
1936
|
+
for (const { sha, reason, riskLevel } of blockedCommits) {
|
|
1937
|
+
const badge = riskLevel ? ` [${riskLevel}]` : "";
|
|
1938
|
+
prBodyLines.push(`- \`${sha.slice(0, 8)}\`${badge} — ${reason}`);
|
|
1939
|
+
}
|
|
1940
|
+
prBodyLines.push("");
|
|
1941
|
+
}
|
|
1942
|
+
if (unaccounted.length > 0) {
|
|
1943
|
+
prBodyLines.push("### 🔴 Unaccounted commits (agent processing gap)", "");
|
|
1944
|
+
for (const sha of unaccounted) prBodyLines.push(`- \`${sha.slice(0, 8)}\` — not processed or reported by agent`);
|
|
1945
|
+
prBodyLines.push("");
|
|
1946
|
+
}
|
|
1947
|
+
if (agentDecisions.length > 0) {
|
|
1948
|
+
prBodyLines.push("### Agent decision log", "");
|
|
1949
|
+
for (const decision of agentDecisions) prBodyLines.push(`- ${decision}`);
|
|
1950
|
+
prBodyLines.push("");
|
|
1951
|
+
}
|
|
1952
|
+
const report = prBodyLines.join("\n");
|
|
1953
|
+
const agentState = {
|
|
1954
|
+
generatedAt: date,
|
|
1955
|
+
appliedShas: applied.map((r) => r.sha),
|
|
1956
|
+
blockedShas: blockedCommits.map((c) => c.sha),
|
|
1957
|
+
needsReviewShas: needsReview.map((r) => r.sha),
|
|
1958
|
+
unaccountedShas: unaccounted
|
|
1959
|
+
};
|
|
1960
|
+
const mermaidBlock = await generateMermaidDiagram(config, [
|
|
1961
|
+
`Upstream: ${upstreamRef} Fork: ${forkRef}`,
|
|
1962
|
+
`Applied (${applied.length}): ${applied.map((r) => `${r.sha.slice(0, 8)} [${r.riskLevel}] ${r.subject}`).join(" | ") || "none"}`,
|
|
1963
|
+
`Blocked (${blockedCommits.length}): ${blockedCommits.map((c) => `${c.sha.slice(0, 8)} — ${c.reason}`).join(" | ") || "none"}`,
|
|
1964
|
+
`Needs review (${needsReview.length}): ${needsReview.map((r) => r.sha.slice(0, 8)).join(", ") || "none"}`,
|
|
1965
|
+
`AI calls: ${readPromptLog(promptLogPath).map((e) => `${e.tool}(${e.model}, ${e.durationMs}ms)`).join(", ") || "none"}`
|
|
1966
|
+
].join("\n"));
|
|
1967
|
+
const promptEntries = readPromptLog(promptLogPath);
|
|
1968
|
+
const detailedLines = [
|
|
1969
|
+
"# Backport Agent — Detailed Run Report",
|
|
1970
|
+
"",
|
|
1971
|
+
"> This file is generated automatically. It is intended for human analysts and",
|
|
1972
|
+
"> AI reasoning models to assess agent performance, decision quality, and LLM behavior.",
|
|
1973
|
+
"",
|
|
1974
|
+
`**Run timestamp**: ${date}`,
|
|
1975
|
+
`**Models used**: fast=\`${config.models.fast}\` powerful=\`${config.models.powerful}\``,
|
|
1976
|
+
`**Prompt log**: \`${promptLogPath}\``,
|
|
1977
|
+
"",
|
|
1978
|
+
"---",
|
|
1979
|
+
"",
|
|
1980
|
+
"## Summary (PR body)",
|
|
1981
|
+
"",
|
|
1982
|
+
report,
|
|
1983
|
+
"",
|
|
1984
|
+
"---",
|
|
1985
|
+
"",
|
|
1986
|
+
"## Agent Workflow Diagram",
|
|
1987
|
+
"",
|
|
1988
|
+
"> Generated by the fast model from the run summary. Evaluate the agent's decision path.",
|
|
1989
|
+
"",
|
|
1990
|
+
mermaidBlock,
|
|
1991
|
+
"",
|
|
1992
|
+
"---",
|
|
1993
|
+
"",
|
|
1994
|
+
`## AI Sub-Agent Call Log (${promptEntries.length} call${promptEntries.length === 1 ? "" : "s"})`,
|
|
1995
|
+
"",
|
|
1996
|
+
"> Each entry is one sub-agent invocation. Review prompt/response pairs to assess",
|
|
1997
|
+
"> reasoning quality, hallucinations, and decision accuracy.",
|
|
1998
|
+
""
|
|
1999
|
+
];
|
|
2000
|
+
for (let i = 0; i < promptEntries.length; i++) {
|
|
2001
|
+
const e = promptEntries[i];
|
|
2002
|
+
const statusBadge = e.error ? "❌ Error" : "✅ OK";
|
|
2003
|
+
detailedLines.push(`### Call ${i + 1} / ${promptEntries.length} — \`${e.tool}\` ${statusBadge}`, "", `| Field | Value |`, `|---|---|`, `| **Timestamp** | ${e.timestamp} |`, `| **Tool** | \`${e.tool}\` |`, `| **Model** | \`${e.model}\` |`, `| **Duration** | ${e.durationMs} ms |`, ...e.inputTokens != null ? [`| **Tokens in** | ${e.inputTokens} |`] : [], ...e.outputTokens != null ? [`| **Tokens out** | ${e.outputTokens} |`] : [], ...e.cacheReadTokens != null && e.cacheReadTokens > 0 ? [`| **Cache read** | ${e.cacheReadTokens} |`] : [], ...e.cacheWriteTokens != null && e.cacheWriteTokens > 0 ? [`| **Cache write** | ${e.cacheWriteTokens} |`] : [], ...e.totalCost != null ? [`| **Cost** | $${e.totalCost.toFixed(6)} |`] : [], ...e.error ? [`| **Error** | ${e.error} |`] : [], "", "**Prompt sent to sub-agent:**", "", "```", e.prompt, "```", "", "**Response received:**", "", "```", e.response || "(empty)", "```", "", "---", "");
|
|
2004
|
+
}
|
|
2005
|
+
if (promptEntries.length === 0) detailedLines.push("_No AI sub-agent calls were made during this run._", "", "---", "");
|
|
2006
|
+
if (promptEntries.length > 0) {
|
|
2007
|
+
const totalMs = promptEntries.reduce((sum, e) => sum + e.durationMs, 0);
|
|
2008
|
+
const totalIn = promptEntries.reduce((sum, e) => sum + (e.inputTokens ?? 0), 0);
|
|
2009
|
+
const totalOut = promptEntries.reduce((sum, e) => sum + (e.outputTokens ?? 0), 0);
|
|
2010
|
+
const totalCost = promptEntries.reduce((sum, e) => sum + (e.totalCost ?? 0), 0);
|
|
2011
|
+
const byTool = /* @__PURE__ */ new Map();
|
|
2012
|
+
for (const e of promptEntries) {
|
|
2013
|
+
const cur = byTool.get(e.tool) ?? {
|
|
2014
|
+
count: 0,
|
|
2015
|
+
totalMs: 0,
|
|
2016
|
+
totalIn: 0,
|
|
2017
|
+
totalOut: 0
|
|
2018
|
+
};
|
|
2019
|
+
byTool.set(e.tool, {
|
|
2020
|
+
count: cur.count + 1,
|
|
2021
|
+
totalMs: cur.totalMs + e.durationMs,
|
|
2022
|
+
totalIn: cur.totalIn + (e.inputTokens ?? 0),
|
|
2023
|
+
totalOut: cur.totalOut + (e.outputTokens ?? 0)
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
const hasTokenData = totalIn > 0 || totalOut > 0;
|
|
2027
|
+
detailedLines.push("## Performance Summary", "", `**Total AI time**: ${totalMs} ms across ${promptEntries.length} call(s)`, ...hasTokenData ? [
|
|
2028
|
+
`**Total input tokens**: ${totalIn}`,
|
|
2029
|
+
`**Total output tokens**: ${totalOut}`,
|
|
2030
|
+
...totalCost > 0 ? [`**Total estimated cost**: $${totalCost.toFixed(6)}`] : []
|
|
2031
|
+
] : [], "", hasTokenData ? "| Tool | Calls | Total ms | Avg ms | Input tok | Output tok |" : "| Tool | Calls | Total ms | Avg ms |", hasTokenData ? "|---|---|---|---|---|---|" : "|---|---|---|---|", ...[...byTool.entries()].map(([tool, s]) => hasTokenData ? `| \`${tool}\` | ${s.count} | ${s.totalMs} | ${Math.round(s.totalMs / s.count)} | ${s.totalIn} | ${s.totalOut} |` : `| \`${tool}\` | ${s.count} | ${s.totalMs} | ${Math.round(s.totalMs / s.count)} |`), "");
|
|
2032
|
+
}
|
|
2033
|
+
if (config.sync.dryRun) process.stderr.write("[Report] Dry-run mode — detailed report not written to disk.\n");
|
|
2034
|
+
else try {
|
|
2035
|
+
const destDir = resolve(config.workingDir, config.report.destination);
|
|
2036
|
+
mkdirSync(destDir, { recursive: true });
|
|
2037
|
+
const reportFilename = `report.${timestampSlug}.md`;
|
|
2038
|
+
const reportFilePath = join(destDir, reportFilename);
|
|
2039
|
+
writeFileSync(reportFilePath, detailedLines.join("\n"), "utf8");
|
|
2040
|
+
process.stderr.write(`[Report] Detailed report written to: ${reportFilePath}\n`);
|
|
2041
|
+
const relPath = relative(config.workingDir, reportFilePath);
|
|
2042
|
+
if (syncBranch && !relPath.startsWith("..")) {
|
|
2043
|
+
git(["add", relPath], config.workingDir);
|
|
2044
|
+
git([
|
|
2045
|
+
"commit",
|
|
2046
|
+
"-m",
|
|
2047
|
+
`chore(backport): add run report ${reportFilename}`
|
|
2048
|
+
], config.workingDir);
|
|
2049
|
+
git([
|
|
2050
|
+
"push",
|
|
2051
|
+
config.fork.remote,
|
|
2052
|
+
syncBranch
|
|
2053
|
+
], config.workingDir);
|
|
2054
|
+
process.stderr.write(`[Report] Report committed and pushed to ${config.fork.remote}/${syncBranch}\n`);
|
|
2055
|
+
}
|
|
2056
|
+
} catch (err) {
|
|
2057
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2058
|
+
process.stderr.write(`[Report] Warning: could not write/commit detailed report: ${msg}\n`);
|
|
2059
|
+
}
|
|
2060
|
+
return {
|
|
2061
|
+
report,
|
|
2062
|
+
agentState,
|
|
2063
|
+
allPassed,
|
|
2064
|
+
needsHumanReview: needsReview.length > 0
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
//#endregion
|
|
2070
|
+
//#region src/ai/ai-tools.ts
|
|
2071
|
+
/**
|
|
2072
|
+
* @file ai/ai-tools.ts
|
|
2073
|
+
*
|
|
2074
|
+
* AI-powered agent tools that spawn focused sub-agents for tasks requiring
|
|
2075
|
+
* deep language model reasoning beyond what the deterministic rule engine
|
|
2076
|
+
* can provide.
|
|
2077
|
+
*
|
|
2078
|
+
* **Why sub-agents?**
|
|
2079
|
+
* The main backport agent drives the orchestration loop and calls deterministic
|
|
2080
|
+
* git/GitHub/validation tools. Some decisions — conflict resolution, semantic
|
|
2081
|
+
* understanding of diffs, customisation compatibility — benefit from a dedicated
|
|
2082
|
+
* LLM call with a narrowly scoped system prompt and no distracting tool context.
|
|
2083
|
+
* Spawning a sub-`Agent` with `tools: []` gives exactly that: a single-turn
|
|
2084
|
+
* reasoning call that returns structured JSON.
|
|
2085
|
+
*
|
|
2086
|
+
* **Tools exported by `makeAiTools`:**
|
|
2087
|
+
*
|
|
2088
|
+
* 1. `resolve_conflict_with_ai` — Given a three-way conflict (base / ours /
|
|
2089
|
+
* theirs), produce a resolved file body with no conflict markers.
|
|
2090
|
+
* Uses `config.models.powerful` for maximum reasoning quality.
|
|
2091
|
+
*
|
|
2092
|
+
* 2. `analyze_commit_for_backport` — Given a commit SHA, message, diff, and
|
|
2093
|
+
* changed-file list, produce a structured semantic assessment of what the
|
|
2094
|
+
* commit does and how risky it is to backport.
|
|
2095
|
+
* Uses `config.models.fast` (analytical but not critical-path).
|
|
2096
|
+
*
|
|
2097
|
+
* 3. `check_customization_compatibility` — Given a diff and a list of
|
|
2098
|
+
* customisation records (pattern + description), reason about whether the
|
|
2099
|
+
* upstream changes could semantically break fork-specific behaviour even
|
|
2100
|
+
* if no textual conflict exists.
|
|
2101
|
+
* Uses `config.models.fast`.
|
|
2102
|
+
*
|
|
2103
|
+
* **JSON extraction:**
|
|
2104
|
+
* Sub-agents are instructed to emit only a JSON object. In practice, some
|
|
2105
|
+
* models wrap the object in a markdown code fence. `extractJson` strips the
|
|
2106
|
+
* fence when present so `JSON.parse` always receives clean text.
|
|
2107
|
+
*
|
|
2108
|
+
* **Error handling:**
|
|
2109
|
+
* If the sub-agent call fails (network error, model error, parse error), each
|
|
2110
|
+
* tool returns a structured fallback object with `error` set rather than
|
|
2111
|
+
* throwing, so the main agent can log the failure and continue.
|
|
2112
|
+
*/
|
|
2113
|
+
/**
|
|
2114
|
+
* Attempts to extract a JSON value from raw LLM output.
|
|
2115
|
+
*
|
|
2116
|
+
* The LLM may wrap its JSON in a markdown code fence (```` ```json … ``` ````
|
|
2117
|
+
* or ```` ``` … ``` ````). This helper strips the fences when present so
|
|
2118
|
+
* `JSON.parse` receives valid JSON text.
|
|
2119
|
+
*
|
|
2120
|
+
* @typeParam T - Expected shape of the parsed value.
|
|
2121
|
+
* @param text - Raw string output from the sub-agent.
|
|
2122
|
+
* @returns The parsed value cast to `T`.
|
|
2123
|
+
* @throws `SyntaxError` if no valid JSON can be found in the text.
|
|
2124
|
+
*/
|
|
2125
|
+
function extractJson(text) {
|
|
2126
|
+
const jsonFenceMatch = text.match(/```json\s*([\s\S]*?)```/);
|
|
2127
|
+
if (jsonFenceMatch) return JSON.parse(jsonFenceMatch[1].trim());
|
|
2128
|
+
const genericFenceMatch = text.match(/```\s*([\s\S]*?)```/);
|
|
2129
|
+
if (genericFenceMatch) return JSON.parse(genericFenceMatch[1].trim());
|
|
2130
|
+
const inlineObjectMatch = text.match(/\{[\s\S]*\}/);
|
|
2131
|
+
if (inlineObjectMatch) return JSON.parse(inlineObjectMatch[0]);
|
|
2132
|
+
return JSON.parse(text.trim());
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Appends a single prompt/response record to the run's JSONL log file.
|
|
2136
|
+
*
|
|
2137
|
+
* The log file path is `./run-<timestamp>.prompts.jsonl` (relative to cwd).
|
|
2138
|
+
* It is created on first write and appended on subsequent calls.
|
|
2139
|
+
* Each line is a self-contained JSON object — suitable for streaming analysis
|
|
2140
|
+
* and incremental loading without parsing the entire file.
|
|
2141
|
+
*
|
|
2142
|
+
* @param logPath - Absolute path to the JSONL log file for this run.
|
|
2143
|
+
* @param toolName - The backport agent tool that invoked the sub-agent.
|
|
2144
|
+
* @param modelId - LLM model identifier used for this call.
|
|
2145
|
+
* @param prompt - Full user prompt sent to the sub-agent.
|
|
2146
|
+
* @param response - Raw text response received from the sub-agent.
|
|
2147
|
+
* @param durationMs - Wall-clock time for the sub-agent call in milliseconds.
|
|
2148
|
+
* @param error - Optional error message if the call failed.
|
|
2149
|
+
* @param usage - Optional token usage breakdown from the LLM response.
|
|
2150
|
+
*/
|
|
2151
|
+
function logPrompt(logPath, toolName, modelId, prompt, response, durationMs, error, usage) {
|
|
2152
|
+
const record = {
|
|
2153
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2154
|
+
tool: toolName,
|
|
2155
|
+
model: modelId,
|
|
2156
|
+
durationMs,
|
|
2157
|
+
prompt,
|
|
2158
|
+
response,
|
|
2159
|
+
...error ? { error } : {},
|
|
2160
|
+
...usage ? {
|
|
2161
|
+
inputTokens: usage.inputTokens,
|
|
2162
|
+
outputTokens: usage.outputTokens,
|
|
2163
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
2164
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
2165
|
+
...usage.totalCost != null ? { totalCost: usage.totalCost } : {}
|
|
2166
|
+
} : {}
|
|
2167
|
+
};
|
|
2168
|
+
try {
|
|
2169
|
+
appendFileSync(logPath, JSON.stringify(record) + "\n", "utf8");
|
|
2170
|
+
} catch {
|
|
2171
|
+
process.stderr.write(`[PromptLogger] Warning: could not write to ${logPath}\n`);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Creates a minimal sub-`Agent` configured with the keypoollive provider.
|
|
2176
|
+
*
|
|
2177
|
+
* The sub-agent has an empty tools array — it performs a single reasoning turn
|
|
2178
|
+
* and returns its text output via `result.outputText`.
|
|
2179
|
+
*
|
|
2180
|
+
* @param modelId - Model identifier to use (fast or powerful).
|
|
2181
|
+
* @param systemPrompt - System prompt that scopes the sub-agent's behaviour.
|
|
2182
|
+
* @returns A configured `Agent` instance ready to call `.run(userPrompt)`.
|
|
2183
|
+
*/
|
|
2184
|
+
function makeSubAgent(modelId, systemPrompt) {
|
|
2185
|
+
return new Agent({
|
|
2186
|
+
providerId: "keypoollive",
|
|
2187
|
+
modelId,
|
|
2188
|
+
apiKey: "auto",
|
|
2189
|
+
systemPrompt,
|
|
2190
|
+
tools: []
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Builds and returns all AI-powered agent tools pre-bound to the provided config.
|
|
2195
|
+
*
|
|
2196
|
+
* @param config - Validated `SyncConfig` loaded from `config.json`.
|
|
2197
|
+
* @param logPath - Absolute path to the JSONL prompt log file for this run.
|
|
2198
|
+
* Created by `main.ts` as `run-<timestamp>.prompts.jsonl`.
|
|
2199
|
+
* @returns Array of three agent tools for AI-assisted analysis.
|
|
2200
|
+
*/
|
|
2201
|
+
function makeAiTools(config, logPath) {
|
|
2202
|
+
return [
|
|
2203
|
+
defineTool({
|
|
2204
|
+
name: "resolve_conflict_with_ai",
|
|
2205
|
+
description: "Resolves a three-way merge conflict in a single file using AI reasoning. Provide the base (common ancestor), our (fork) version, and their (upstream) version of the file, plus the upstream commit message for context. Returns the resolved file content with no conflict markers, a confidence level, and a brief reasoning summary. Use this when cherry_pick_commit reports a conflict and you need to resolve a specific file.",
|
|
2206
|
+
inputSchema: z.object({
|
|
2207
|
+
/**
|
|
2208
|
+
* Repository-relative path to the conflicted file (e.g. `src/core/api/index.ts`).
|
|
2209
|
+
* Used only for display/reasoning context — no filesystem access is performed.
|
|
2210
|
+
*/
|
|
2211
|
+
filePath: z.string().describe("Repo-relative path of the conflicted file"),
|
|
2212
|
+
/**
|
|
2213
|
+
* Content of the file at the common ancestor commit (merge base).
|
|
2214
|
+
* May be an empty string when the file was created on both branches independently.
|
|
2215
|
+
*/
|
|
2216
|
+
baseContent: z.string().describe("File content at the common ancestor (merge base); empty string if none"),
|
|
2217
|
+
/**
|
|
2218
|
+
* Content of the file in the fork branch (our version, with our customisations).
|
|
2219
|
+
*/
|
|
2220
|
+
ourContent: z.string().describe("File content in the fork branch (our customised version)"),
|
|
2221
|
+
/**
|
|
2222
|
+
* Content of the file in the upstream commit (their version).
|
|
2223
|
+
*/
|
|
2224
|
+
theirContent: z.string().describe("File content from the upstream commit (their version)"),
|
|
2225
|
+
/**
|
|
2226
|
+
* The full commit message of the upstream change being cherry-picked.
|
|
2227
|
+
* Helps the model understand the intent of the upstream change.
|
|
2228
|
+
*/
|
|
2229
|
+
commitMessage: z.string().describe("Upstream commit message, used to understand the intent of the change"),
|
|
2230
|
+
/**
|
|
2231
|
+
* Optional: a human-readable note describing relevant fork customisations
|
|
2232
|
+
* in this file (e.g. "This file contains the keypoollive provider registration").
|
|
2233
|
+
* When provided, the model uses it to decide which parts must not be overwritten.
|
|
2234
|
+
*/
|
|
2235
|
+
customizationNote: z.string().optional().describe("Optional description of fork customisations in this file to help preserve them")
|
|
2236
|
+
}),
|
|
2237
|
+
execute: async ({ filePath, baseContent, ourContent, theirContent, commitMessage, customizationNote }) => {
|
|
2238
|
+
/**
|
|
2239
|
+
* System prompt focuses the sub-agent exclusively on conflict resolution.
|
|
2240
|
+
* The model must output only a single JSON object.
|
|
2241
|
+
*/
|
|
2242
|
+
const systemPrompt = [
|
|
2243
|
+
"You are an expert software engineer specialising in Git merge conflict resolution.",
|
|
2244
|
+
"Your sole task is to produce a clean merged version of a conflicted file.",
|
|
2245
|
+
"",
|
|
2246
|
+
"Rules:",
|
|
2247
|
+
"- Output ONLY a valid JSON object. No prose, no explanations outside the JSON.",
|
|
2248
|
+
"- The JSON must have exactly three fields: \"resolvedContent\", \"confidence\", \"reasoning\".",
|
|
2249
|
+
"- \"resolvedContent\": the complete resolved file content as a string. MUST contain zero conflict markers (<<<<<<<, =======, >>>>>>>).",
|
|
2250
|
+
"- \"confidence\": one of \"high\", \"medium\", or \"low\".",
|
|
2251
|
+
" - \"high\": you are certain the resolution is correct and preserves all intent.",
|
|
2252
|
+
" - \"medium\": the resolution is plausible but you had to make a judgment call.",
|
|
2253
|
+
" - \"low\": the resolution is uncertain; a human should review it.",
|
|
2254
|
+
"- \"reasoning\": a single sentence explaining the key decision you made.",
|
|
2255
|
+
"",
|
|
2256
|
+
"Resolution strategy:",
|
|
2257
|
+
"1. Understand what the UPSTREAM change was trying to achieve (read the commit message).",
|
|
2258
|
+
"2. Understand what the FORK version preserves (customisations, local patches).",
|
|
2259
|
+
"3. Integrate both intents: keep fork customisations AND apply the upstream change where safe.",
|
|
2260
|
+
"4. When in doubt, prefer preserving fork customisations and mark confidence as 'low'."
|
|
2261
|
+
].join("\n");
|
|
2262
|
+
const customizationSection = customizationNote ? `\nFork customisation context:\n${customizationNote}\n` : "";
|
|
2263
|
+
const userPrompt = `Resolve the merge conflict in file: ${filePath}\nUpstream commit message: ${commitMessage}\n` + customizationSection + `\n--- BASE (common ancestor) ---\n${baseContent || "(file did not exist at merge base)"}\n\n--- OURS (fork version) ---\n${ourContent}\n\n--- THEIRS (upstream version) ---\n${theirContent}\n\nOutput the JSON object now.`;
|
|
2264
|
+
const modelsToTry = [{
|
|
2265
|
+
modelId: config.models.specialist,
|
|
2266
|
+
label: "specialist"
|
|
2267
|
+
}, {
|
|
2268
|
+
modelId: config.models.powerful,
|
|
2269
|
+
label: "powerful"
|
|
2270
|
+
}];
|
|
2271
|
+
let lastError = null;
|
|
2272
|
+
for (const { modelId, label } of modelsToTry) try {
|
|
2273
|
+
const subAgent = makeSubAgent(modelId, systemPrompt);
|
|
2274
|
+
const t0 = Date.now();
|
|
2275
|
+
const result = await subAgent.run(userPrompt);
|
|
2276
|
+
const durationMs = Date.now() - t0;
|
|
2277
|
+
logPrompt(logPath, "resolve_conflict_with_ai", modelId, userPrompt, result.outputText ?? "", durationMs, null, result.usage);
|
|
2278
|
+
const output = extractJson(result.outputText ?? "");
|
|
2279
|
+
return {
|
|
2280
|
+
resolvedContent: output.resolvedContent,
|
|
2281
|
+
confidence: output.confidence,
|
|
2282
|
+
reasoning: output.reasoning,
|
|
2283
|
+
error: null
|
|
2284
|
+
};
|
|
2285
|
+
} catch (err) {
|
|
2286
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
2287
|
+
process.stderr.write(`[resolve_conflict_with_ai] ${label} model (${modelId}) failed: ${lastError.slice(0, 120)} — ${label === "specialist" ? "retrying with powerful model" : "giving up"}\n`);
|
|
2288
|
+
logPrompt(logPath, "resolve_conflict_with_ai", modelId, userPrompt, "", 0, lastError);
|
|
2289
|
+
}
|
|
2290
|
+
return {
|
|
2291
|
+
resolvedContent: "",
|
|
2292
|
+
confidence: "low",
|
|
2293
|
+
reasoning: `AI resolution failed on all models: ${lastError}`,
|
|
2294
|
+
error: lastError
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
}),
|
|
2298
|
+
defineTool({
|
|
2299
|
+
name: "analyze_commit_for_backport",
|
|
2300
|
+
description: "Performs a semantic analysis of an upstream commit to understand its intent and assess how complex it is to backport into the fork. Returns a structured assessment with a human-readable summary, key changes, complexity rating, semantic risk factors, and a backport recommendation. Use this before cherry-picking a high-risk commit to better understand what it does.",
|
|
2301
|
+
inputSchema: z.object({
|
|
2302
|
+
/**
|
|
2303
|
+
* Full commit SHA (40-character hex string).
|
|
2304
|
+
*/
|
|
2305
|
+
sha: z.string().describe("Full commit SHA"),
|
|
2306
|
+
/**
|
|
2307
|
+
* The commit's subject + body as returned by `git log --format=%B`.
|
|
2308
|
+
*/
|
|
2309
|
+
commitMessage: z.string().describe("Full commit message (subject + body)"),
|
|
2310
|
+
/**
|
|
2311
|
+
* Full unified diff of the commit as returned by `git show --format=` or
|
|
2312
|
+
* `git diff <parent> <sha>`.
|
|
2313
|
+
*/
|
|
2314
|
+
diff: z.string().describe("Full unified diff of the commit"),
|
|
2315
|
+
/**
|
|
2316
|
+
* List of file paths changed by this commit (relative to repo root).
|
|
2317
|
+
*/
|
|
2318
|
+
changedFiles: z.array(z.string()).describe("List of file paths changed by this commit")
|
|
2319
|
+
}),
|
|
2320
|
+
execute: async ({ sha, commitMessage, diff, changedFiles }) => {
|
|
2321
|
+
const systemPrompt = [
|
|
2322
|
+
"You are an expert software engineer specialising in Git history analysis.",
|
|
2323
|
+
"Your task is to analyse a single upstream commit and assess how complex it is to",
|
|
2324
|
+
"backport it into a heavily customised fork.",
|
|
2325
|
+
"",
|
|
2326
|
+
"Output ONLY a valid JSON object with exactly these fields:",
|
|
2327
|
+
" \"summary\": string — 2-3 sentence description of what this commit does.",
|
|
2328
|
+
" \"keyChanges\": string[] — bullet-point list of the most important code changes.",
|
|
2329
|
+
" \"backportComplexity\": \"trivial\" | \"moderate\" | \"complex\"",
|
|
2330
|
+
" - \"trivial\": small, isolated change with no side effects.",
|
|
2331
|
+
" - \"moderate\": meaningful change but scope is clear and contained.",
|
|
2332
|
+
" - \"complex\": refactor, API change, or broad change that may interact with customisations.",
|
|
2333
|
+
" \"semanticRiskFactors\": string[] — list of reasons why this commit could break a fork",
|
|
2334
|
+
" (e.g. \"renames exported interface\", \"changes provider registration pattern\").",
|
|
2335
|
+
" Empty array if no risks detected.",
|
|
2336
|
+
" \"recommendation\": \"apply\" | \"apply-with-care\" | \"review-required\" | \"skip\"",
|
|
2337
|
+
" - \"apply\": safe to cherry-pick automatically.",
|
|
2338
|
+
" - \"apply-with-care\": cherry-pick but verify validation passes.",
|
|
2339
|
+
" - \"review-required\": human should review before merging.",
|
|
2340
|
+
" - \"skip\": commit should not be backported."
|
|
2341
|
+
].join("\n");
|
|
2342
|
+
const userPrompt = `Commit SHA: ${sha}\nCommit message:\n${commitMessage}\n\nChanged files (${changedFiles.length}):\n${changedFiles.join("\n")}\n\nDiff:\n${diff}\n\nOutput the JSON object now.`;
|
|
2343
|
+
try {
|
|
2344
|
+
const subAgent = makeSubAgent(config.models.fast, systemPrompt);
|
|
2345
|
+
const t0 = Date.now();
|
|
2346
|
+
const result = await subAgent.run(userPrompt);
|
|
2347
|
+
const durationMs = Date.now() - t0;
|
|
2348
|
+
logPrompt(logPath, "analyze_commit_for_backport", config.models.fast, userPrompt, result.outputText ?? "", durationMs, null, result.usage);
|
|
2349
|
+
const output = extractJson(result.outputText ?? "");
|
|
2350
|
+
return {
|
|
2351
|
+
summary: output.summary,
|
|
2352
|
+
keyChanges: output.keyChanges,
|
|
2353
|
+
backportComplexity: output.backportComplexity,
|
|
2354
|
+
semanticRiskFactors: output.semanticRiskFactors,
|
|
2355
|
+
recommendation: output.recommendation,
|
|
2356
|
+
error: null
|
|
2357
|
+
};
|
|
2358
|
+
} catch (err) {
|
|
2359
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2360
|
+
logPrompt(logPath, "analyze_commit_for_backport", config.models.fast, userPrompt, "", 0, message);
|
|
2361
|
+
return {
|
|
2362
|
+
summary: `Analysis failed: ${message}`,
|
|
2363
|
+
keyChanges: [],
|
|
2364
|
+
backportComplexity: "complex",
|
|
2365
|
+
semanticRiskFactors: [`AI analysis unavailable: ${message}`],
|
|
2366
|
+
recommendation: "review-required",
|
|
2367
|
+
error: message
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
}),
|
|
2372
|
+
defineTool({
|
|
2373
|
+
name: "check_customization_compatibility",
|
|
2374
|
+
description: "Checks whether an upstream diff is semantically compatible with the fork's customisations. Goes beyond file-path glob matching: the AI reads the customisation descriptions and reasons about whether the upstream change could break declared fork-specific behaviour. Returns a compatibility verdict, a list of affected customisations, semantic conflicts, warnings, and a recommendation. Use this when classify_commit_risk returns 'medium' or 'high' and you want deeper insight.",
|
|
2375
|
+
inputSchema: z.object({
|
|
2376
|
+
/**
|
|
2377
|
+
* Full unified diff of the upstream commit being evaluated.
|
|
2378
|
+
*/
|
|
2379
|
+
diff: z.string().describe("Full unified diff of the upstream commit"),
|
|
2380
|
+
/**
|
|
2381
|
+
* List of customisation entries loaded from `customizations.yaml`.
|
|
2382
|
+
* Each entry has a glob pattern (identifying which files are customised)
|
|
2383
|
+
* and a human-readable description of what the customisation does.
|
|
2384
|
+
*/
|
|
2385
|
+
customizations: z.array(z.object({
|
|
2386
|
+
/**
|
|
2387
|
+
* Glob pattern (e.g. `src/core/api/providers/**`) identifying files
|
|
2388
|
+
* that belong to this customisation zone.
|
|
2389
|
+
*/
|
|
2390
|
+
pattern: z.string().describe("Glob pattern for customised file paths"),
|
|
2391
|
+
/**
|
|
2392
|
+
* Human-readable description of what this customisation does and why
|
|
2393
|
+
* it must be preserved.
|
|
2394
|
+
*/
|
|
2395
|
+
description: z.string().describe("Description of the fork customisation")
|
|
2396
|
+
})).describe("Customisation entries from customizations.yaml")
|
|
2397
|
+
}),
|
|
2398
|
+
execute: async ({ diff, customizations }) => {
|
|
2399
|
+
/**
|
|
2400
|
+
* If there are no customisations defined, there is nothing to check.
|
|
2401
|
+
* Return a trivially compatible result without calling the LLM.
|
|
2402
|
+
*/
|
|
2403
|
+
if (customizations.length === 0) return {
|
|
2404
|
+
compatible: true,
|
|
2405
|
+
affectedCustomizations: [],
|
|
2406
|
+
semanticConflicts: [],
|
|
2407
|
+
warnings: [],
|
|
2408
|
+
recommendation: "No customisations defined; upstream change is safe to apply.",
|
|
2409
|
+
error: null
|
|
2410
|
+
};
|
|
2411
|
+
const customizationList = customizations.map((c, i) => ` ${i + 1}. Pattern: ${c.pattern}\n Description: ${c.description}`).join("\n");
|
|
2412
|
+
const systemPrompt = [
|
|
2413
|
+
"You are an expert software engineer specialising in fork maintenance and semantic conflict detection.",
|
|
2414
|
+
"Your task is to assess whether an upstream Git diff could break the customised behaviour",
|
|
2415
|
+
"of a fork, given a list of declared customisations.",
|
|
2416
|
+
"",
|
|
2417
|
+
"Output ONLY a valid JSON object with exactly these fields:",
|
|
2418
|
+
" \"compatible\": boolean — true if the upstream change is unlikely to break any customisation.",
|
|
2419
|
+
" \"affectedCustomizations\": string[] — names/patterns of customisations potentially affected.",
|
|
2420
|
+
" \"semanticConflicts\": string[] — specific ways the upstream change could break fork behaviour.",
|
|
2421
|
+
" Each entry is a concrete description (e.g. \"renames ApiProvider enum",
|
|
2422
|
+
" value used by keypoollive provider registration\").",
|
|
2423
|
+
" Empty array if no conflicts detected.",
|
|
2424
|
+
" \"warnings\": string[] — non-blocking concerns worth noting (e.g. \"touches shared types",
|
|
2425
|
+
" used by customised components\").",
|
|
2426
|
+
" Empty array if none.",
|
|
2427
|
+
" \"recommendation\": string — one sentence advising the agent what to do.",
|
|
2428
|
+
"",
|
|
2429
|
+
"Important: focus on SEMANTIC compatibility, not textual conflicts.",
|
|
2430
|
+
"A change can be textually clean but still break the fork (e.g. renaming an interface",
|
|
2431
|
+
"that the fork's custom provider implements)."
|
|
2432
|
+
].join("\n");
|
|
2433
|
+
const userPrompt = `Declared fork customisations:\n${customizationList}\n\nUpstream diff to evaluate:\n${diff}\n\nOutput the JSON object now.`;
|
|
2434
|
+
try {
|
|
2435
|
+
const subAgent = makeSubAgent(config.models.fast, systemPrompt);
|
|
2436
|
+
const t0 = Date.now();
|
|
2437
|
+
const result = await subAgent.run(userPrompt);
|
|
2438
|
+
const durationMs = Date.now() - t0;
|
|
2439
|
+
logPrompt(logPath, "check_customization_compatibility", config.models.fast, userPrompt, result.outputText ?? "", durationMs, null, result.usage);
|
|
2440
|
+
const output = extractJson(result.outputText ?? "");
|
|
2441
|
+
return {
|
|
2442
|
+
compatible: output.compatible,
|
|
2443
|
+
affectedCustomizations: output.affectedCustomizations,
|
|
2444
|
+
semanticConflicts: output.semanticConflicts,
|
|
2445
|
+
warnings: output.warnings,
|
|
2446
|
+
recommendation: output.recommendation,
|
|
2447
|
+
error: null
|
|
2448
|
+
};
|
|
2449
|
+
} catch (err) {
|
|
2450
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2451
|
+
logPrompt(logPath, "check_customization_compatibility", config.models.fast, userPrompt, "", 0, message);
|
|
2452
|
+
return {
|
|
2453
|
+
compatible: false,
|
|
2454
|
+
affectedCustomizations: [],
|
|
2455
|
+
semanticConflicts: [],
|
|
2456
|
+
warnings: [`AI compatibility check failed: ${message}`],
|
|
2457
|
+
recommendation: "Treat as potentially incompatible; request human review.",
|
|
2458
|
+
error: message
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
})
|
|
2463
|
+
];
|
|
2464
|
+
}
|
|
2465
|
+
//#endregion
|
|
2466
|
+
//#region src/main.ts
|
|
2467
|
+
/**
|
|
2468
|
+
* @file main.ts
|
|
2469
|
+
*
|
|
2470
|
+
* Entry point for the Backport Agent CLI.
|
|
2471
|
+
*
|
|
2472
|
+
* **Initialization sequence:**
|
|
2473
|
+
* 1. Parse CLI arguments (`--verbose`, `--config`, `--backport-customizations`,
|
|
2474
|
+
* `--keypool-vault-url`, `--keypool-live-secret`, `--keypool-state-file`, `--dry-run`).
|
|
2475
|
+
* 2. Validate required environment variables (`KEYPOOL_VAULT_URL` or `KEYPOOL_LIVE_SECRET`).
|
|
2476
|
+
* 3. Load and validate `config.json` via `loadConfig()`.
|
|
2477
|
+
* 4. Load and validate `customizations.yaml` via `loadCustomizations()`.
|
|
2478
|
+
* 5. Assemble all agent tools from the individual factory functions.
|
|
2479
|
+
* 6. Instantiate the `Agent` with the keypoollive provider, system prompt, and tools.
|
|
2480
|
+
* 7. Subscribe to runtime events to stream assistant output to stdout.
|
|
2481
|
+
* 8. Call `agent.run(task)` with the sync task description.
|
|
2482
|
+
* 9. Print the final report (or exit with code 1 on any fatal error).
|
|
2483
|
+
*
|
|
2484
|
+
* **Provider:**
|
|
2485
|
+
* `keypoollive` with `apiKey: "auto"` uses the `KEYPOOL_VAULT_URL` environment
|
|
2486
|
+
* variable to resolve API keys at runtime via an encrypted vault, enabling
|
|
2487
|
+
* automatic key rotation without storing secrets in the codebase.
|
|
2488
|
+
*/
|
|
2489
|
+
{
|
|
2490
|
+
const argv = process.argv.slice(2);
|
|
2491
|
+
function getArgValue(name) {
|
|
2492
|
+
const idx = argv.indexOf(name);
|
|
2493
|
+
return idx !== -1 && idx + 1 < argv.length ? argv[idx + 1] : void 0;
|
|
2494
|
+
}
|
|
2495
|
+
function hasFlag(name) {
|
|
2496
|
+
return argv.includes(name);
|
|
2497
|
+
}
|
|
2498
|
+
if (hasFlag("--verbose")) process.env.VERBOSE = "true";
|
|
2499
|
+
if (hasFlag("--dry-run")) process.env.DRY_RUN = "true";
|
|
2500
|
+
const cliConfig = getArgValue("--config");
|
|
2501
|
+
if (cliConfig) process.env._CLI_CONFIG_PATH = cliConfig;
|
|
2502
|
+
const cliCustomizations = getArgValue("--backport-customizations");
|
|
2503
|
+
if (cliCustomizations) process.env.BACKPORT_CUSTOMIZATIONS = cliCustomizations;
|
|
2504
|
+
const cliVaultUrl = getArgValue("--keypool-vault-url");
|
|
2505
|
+
if (cliVaultUrl) process.env.KEYPOOL_VAULT_URL = cliVaultUrl;
|
|
2506
|
+
const cliLiveSecret = getArgValue("--keypool-live-secret");
|
|
2507
|
+
if (cliLiveSecret) process.env.KEYPOOL_LIVE_SECRET = cliLiveSecret;
|
|
2508
|
+
const cliStateFile = getArgValue("--keypool-state-file");
|
|
2509
|
+
if (cliStateFile) process.env.KEYPOOL_STATE_FILE = cliStateFile;
|
|
2510
|
+
}
|
|
2511
|
+
{
|
|
2512
|
+
const envPath = resolve(process.cwd(), ".env");
|
|
2513
|
+
if (existsSync(envPath)) {
|
|
2514
|
+
const { config } = await import("dotenv");
|
|
2515
|
+
config({ path: envPath });
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
var SYSTEM_PROMPT = `You are the Backport Agent, a specialist in safely synchronizing a customized Git fork with its upstream repository.
|
|
2519
|
+
|
|
2520
|
+
## Your mission
|
|
2521
|
+
Integrate upstream commits into the fork branch while preserving all fork-specific customizations.
|
|
2522
|
+
Produce a draft pull request with a clear report. Never push directly to the main branch.
|
|
2523
|
+
|
|
2524
|
+
## Core workflow (follow this exactly)
|
|
2525
|
+
|
|
2526
|
+
1. Call fetch_remotes to ensure refs are up to date.
|
|
2527
|
+
2. Call list_candidate_commits to get pending upstream commits (already filtered, newest-last).
|
|
2528
|
+
- Record all returned SHAs immediately. You are accountable for every single one.
|
|
2529
|
+
3. For each candidate commit (process ALL of them — no silent skips):
|
|
2530
|
+
a. Call get_commit_details to inspect changed files and diff.
|
|
2531
|
+
b. Call classify_commit_risk to determine risk level deterministically.
|
|
2532
|
+
c. Risk-based decision:
|
|
2533
|
+
- LOW risk: proceed directly to step 5 (cherry-pick). No AI analysis needed.
|
|
2534
|
+
- MEDIUM risk: call analyze_commit_for_backport for context, then proceed to cherry-pick.
|
|
2535
|
+
- HIGH risk (touches a customization zone):
|
|
2536
|
+
* MANDATORY: Call check_customization_compatibility — pass the diff and all affected customization IDs.
|
|
2537
|
+
* MANDATORY: Call analyze_commit_for_backport — pass sha, message, diff, and changed files.
|
|
2538
|
+
* Read both responses carefully:
|
|
2539
|
+
- If both tools confirm the change is SAFE or ORTHOGONAL to the customization (e.g., it modifies a
|
|
2540
|
+
different provider, unrelated docs section, or infrastructure that doesn't overlap with fork code):
|
|
2541
|
+
→ proceed to cherry-pick (step 5). Do NOT block on risk level alone.
|
|
2542
|
+
- If the tools identify a genuine semantic conflict (same code paths, incompatible invariants):
|
|
2543
|
+
→ add to blockedCommits with a precise reason from the AI analysis.
|
|
2544
|
+
- If uncertain: still attempt the cherry-pick; conflicts will surface in step 5c.
|
|
2545
|
+
d. Commits with alreadyApplied: true → record as "skipped" in commitResults.
|
|
2546
|
+
4. Create the sync branch via create_sync_branch (once, before first cherry-pick).
|
|
2547
|
+
5. For each non-skipped commit (process lowest risk first):
|
|
2548
|
+
a. Call cherry_pick_commit.
|
|
2549
|
+
b. If success: record as "applied" in commitResults and proceed to next.
|
|
2550
|
+
c. If conflicts: for each conflicted file, call get_conflict_context, then attempt resolution.
|
|
2551
|
+
- Check the \`forcedStrategy\` field returned by get_conflict_context:
|
|
2552
|
+
* \`forcedStrategy: "ours"\` → use \`forkVersion\` directly as resolvedContent; call apply_resolved_file immediately. No AI call needed.
|
|
2553
|
+
* \`forcedStrategy: "theirs"\` → use \`upstreamVersion\` directly as resolvedContent; call apply_resolved_file immediately. No AI call needed.
|
|
2554
|
+
* \`forcedStrategy: null\` → proceed with AI resolution below.
|
|
2555
|
+
- (When forcedStrategy is null) Call resolve_conflict_with_ai with the base/ours/theirs content to get an AI-proposed resolution.
|
|
2556
|
+
- If confidence is "high" or "medium": verify no conflict markers remain, then call apply_resolved_file, then continue_cherry_pick.
|
|
2557
|
+
- If confidence is "low" or the tool returned an error: call abort_cherry_pick, mark commit as conflict-blocked.
|
|
2558
|
+
6. Call run_validation with the highest risk level encountered in this run.
|
|
2559
|
+
7. If validation fails: note it in the report, mark relevant commits as validation-failed.
|
|
2560
|
+
8. Call push_sync_branch (unless dry-run).
|
|
2561
|
+
9. Call find_existing_sync_pr to check for an existing PR.
|
|
2562
|
+
10. Call generate_report with the full summary of all decisions.
|
|
2563
|
+
11. Call create_sync_pr with the report as body (unless an existing PR was found and up to date).
|
|
2564
|
+
|
|
2565
|
+
## Accountability (enforced — never skip)
|
|
2566
|
+
- You received a finite list of SHAs from list_candidate_commits.
|
|
2567
|
+
- EVERY SHA must appear in generate_report: either in commitResults (as applied/skipped/conflict-blocked/validation-failed) OR in blockedCommits.
|
|
2568
|
+
- No commit may be silently dropped. If you are unsure what to do with a commit, add it to blockedCommits with reason "deferred: needs human triage".
|
|
2569
|
+
- blockedCommits entries MUST include a specific human-readable reason (not just the SHA).
|
|
2570
|
+
- Pass allCandidateShas to generate_report — it cross-checks accountability automatically.
|
|
2571
|
+
|
|
2572
|
+
## Hard constraints (never violate)
|
|
2573
|
+
- NEVER block a commit solely because classify_commit_risk returns "high" — always run the mandatory AI tools first.
|
|
2574
|
+
- NEVER apply a resolved file with conflict markers (<<<, ===, >>>) still present.
|
|
2575
|
+
- NEVER call continue_cherry_pick before all conflicted files are staged.
|
|
2576
|
+
- NEVER fabricate file content — only use content from get_conflict_context.
|
|
2577
|
+
- NEVER run commands that are not available as tools.
|
|
2578
|
+
- NEVER skip generate_report — it ends the run and produces the output.
|
|
2579
|
+
- If KEYPOOL_VAULT_URL is not set and apiKey is "auto", the run will fail before reaching this point.
|
|
2580
|
+
`;
|
|
2581
|
+
/**
|
|
2582
|
+
* Main async entry point.
|
|
2583
|
+
*
|
|
2584
|
+
* Orchestrates the full agent lifecycle from environment validation through
|
|
2585
|
+
* report output. On any unhandled error the process exits with code 1.
|
|
2586
|
+
*
|
|
2587
|
+
* @throws On missing environment variables, invalid config, or agent failure.
|
|
2588
|
+
*/
|
|
2589
|
+
async function main() {
|
|
2590
|
+
if (!process.env.KEYPOOL_VAULT_URL && !process.env.KEYPOOL_LIVE_SECRET) {
|
|
2591
|
+
console.error("Error: KEYPOOL_VAULT_URL environment variable is required for the keypoollive provider.\nSet it to your encrypted vault URL (e.g. https://raw.githubusercontent.com/.../ai.json.XXXX.enc)\nalong with KEYPOOL_LIVE_SECRET as the decryption key.");
|
|
2592
|
+
process.exit(1);
|
|
2593
|
+
}
|
|
2594
|
+
const config = loadConfig(process.env._CLI_CONFIG_PATH);
|
|
2595
|
+
const customizations = await loadCustomizations(config.customizations ?? process.env.BACKPORT_CUSTOMIZATIONS);
|
|
2596
|
+
applyGitAuth(config);
|
|
2597
|
+
ensureWorkingDir(config);
|
|
2598
|
+
const userInstructionService = createUserInstructionConfigService({ skills: { workspacePath: config.workingDir } });
|
|
2599
|
+
await userInstructionService.start();
|
|
2600
|
+
const gitTools = makeGitTools(config);
|
|
2601
|
+
const riskTool = makeRiskTool(config, customizations);
|
|
2602
|
+
const validationTool = makeValidationTool(config);
|
|
2603
|
+
const githubTools = makeGitHubTools(config);
|
|
2604
|
+
const promptLogPath = resolve(`run-${Date.now()}.prompts.jsonl`);
|
|
2605
|
+
process.stderr.write(`[PromptLogger] Writing sub-agent logs to: ${promptLogPath}\n`);
|
|
2606
|
+
const reportTool = makeReportTool(config, promptLogPath);
|
|
2607
|
+
const aiTools = makeAiTools(config, promptLogPath);
|
|
2608
|
+
const allTools = [
|
|
2609
|
+
...createBuiltinTools({
|
|
2610
|
+
cwd: config.workingDir,
|
|
2611
|
+
enableReadFiles: true,
|
|
2612
|
+
enableSearch: true,
|
|
2613
|
+
enableBash: true,
|
|
2614
|
+
enableWebFetch: true,
|
|
2615
|
+
enableApplyPatch: true,
|
|
2616
|
+
enableEditor: true,
|
|
2617
|
+
enableSkills: true,
|
|
2618
|
+
enableAskQuestion: true,
|
|
2619
|
+
enableSubmitAndExit: true,
|
|
2620
|
+
executors: {
|
|
2621
|
+
skills: async (skill, args) => {
|
|
2622
|
+
const configuredSkills = userInstructionService.listRecords("skill");
|
|
2623
|
+
const match = configuredSkills.find((record) => record.id === skill || record.item.name === skill || record.filePath === skill);
|
|
2624
|
+
if (!match || match.item.disabled) {
|
|
2625
|
+
const availableSkills = configuredSkills.filter((record) => !record.item.disabled).map((record) => record.item.name);
|
|
2626
|
+
return availableSkills.length > 0 ? `Skill "${skill}" is not available. Known skills: ${availableSkills.join(", ")}` : `No configured skills are available in this backport-agent runtime.`;
|
|
2627
|
+
}
|
|
2628
|
+
return [
|
|
2629
|
+
`Skill: ${match.item.name}`,
|
|
2630
|
+
match.item.description ? `Description: ${match.item.description}` : null,
|
|
2631
|
+
args ? `Arguments: ${args}` : null,
|
|
2632
|
+
"Instructions:",
|
|
2633
|
+
match.item.instructions
|
|
2634
|
+
].filter(Boolean).join("\n");
|
|
2635
|
+
},
|
|
2636
|
+
askQuestion: async (question, options) => {
|
|
2637
|
+
return `Question recorded (headless mode): ${question} [${options.length > 0 ? options.join(" | ") : "(no options)"}]`;
|
|
2638
|
+
},
|
|
2639
|
+
submit: async (summary, verified) => `submit_and_exit acknowledged (verified=${verified ? "true" : "false"}): ${summary}`
|
|
2640
|
+
}
|
|
2641
|
+
}),
|
|
2642
|
+
...gitTools,
|
|
2643
|
+
riskTool,
|
|
2644
|
+
validationTool,
|
|
2645
|
+
...githubTools,
|
|
2646
|
+
reportTool,
|
|
2647
|
+
...aiTools
|
|
2648
|
+
];
|
|
2649
|
+
const agent = new Agent({
|
|
2650
|
+
providerId: "keypoollive",
|
|
2651
|
+
modelId: config.models.fast,
|
|
2652
|
+
apiKey: "auto",
|
|
2653
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
2654
|
+
tools: allTools,
|
|
2655
|
+
maxIterations: config.sync.maxIterations,
|
|
2656
|
+
completionPolicy: { requireCompletionTool: true }
|
|
2657
|
+
});
|
|
2658
|
+
const verbose = process.env.VERBOSE === "true";
|
|
2659
|
+
let lastEventWasText = false;
|
|
2660
|
+
let iterationOffset = 0;
|
|
2661
|
+
let lastSeenIteration = 0;
|
|
2662
|
+
let currentAttempt = 1;
|
|
2663
|
+
agent.subscribe((event) => {
|
|
2664
|
+
const rawIter = event.iteration;
|
|
2665
|
+
if (typeof rawIter === "number" && rawIter > lastSeenIteration) lastSeenIteration = rawIter;
|
|
2666
|
+
const displayIter = typeof rawIter === "number" ? iterationOffset + rawIter : "?";
|
|
2667
|
+
if (event.type === "assistant-text-delta") {
|
|
2668
|
+
lastEventWasText = true;
|
|
2669
|
+
process.stdout.write(event.text);
|
|
2670
|
+
} else if (event.type === "tool-started" && verbose) {
|
|
2671
|
+
if (lastEventWasText) process.stderr.write("\n");
|
|
2672
|
+
lastEventWasText = false;
|
|
2673
|
+
const inp = event.toolCall.input;
|
|
2674
|
+
const preview = inp && typeof inp === "object" && Object.keys(inp).length > 0 ? Object.keys(inp).slice(0, 2).map((k) => `${k}=${JSON.stringify(inp[k]).slice(0, 60)}`).join(", ") : "(no input)";
|
|
2675
|
+
process.stderr.write(`[→ iter ${displayIter}] ${event.toolCall.toolName}(${preview})\n`);
|
|
2676
|
+
} else if (event.type === "tool-finished" && verbose) {
|
|
2677
|
+
lastEventWasText = false;
|
|
2678
|
+
const result = event.toolCall;
|
|
2679
|
+
process.stderr.write(`[← iter ${displayIter}] ${result.toolName ?? event.toolCall.toolName} done\n`);
|
|
2680
|
+
} else if ((event.type === "iteration_start" || event.type === "turn-started") && verbose) {
|
|
2681
|
+
const retrySuffix = currentAttempt > 1 ? ` - Retry ${currentAttempt - 1}` : "";
|
|
2682
|
+
process.stderr.write(`\n--- iteration ${displayIter}${retrySuffix} ---\n`);
|
|
2683
|
+
}
|
|
2684
|
+
});
|
|
2685
|
+
const dryRunNote = config.sync.dryRun ? " [DRY RUN — no changes will be pushed]" : "";
|
|
2686
|
+
const task = `Synchronize the fork \`${config.fork.repo}@${config.fork.branch}\` with upstream \`${config.upstream.repo}@${config.upstream.branch}\`.${dryRunNote}\n\nWorking directory: ${config.workingDir}\nMax commits per run: ${config.sync.maxCommitsPerRun}\nBatch size: ${config.sync.batchSize}`;
|
|
2687
|
+
console.error(`\n=== Backport Agent starting${dryRunNote} ===\n`);
|
|
2688
|
+
const RETRIABLE_RE = /503|rate.?limit|too many requests|overloaded|service.?unavailable|high.?demand|try again later|temporarily unavailable|exceeded your current quota|quota.*exceeded|check your plan|billing details/i;
|
|
2689
|
+
const BASE_DELAY_MS = 15e3;
|
|
2690
|
+
const MAX_ATTEMPTS = 5;
|
|
2691
|
+
async function runWithRetry() {
|
|
2692
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
2693
|
+
currentAttempt = attempt;
|
|
2694
|
+
let result;
|
|
2695
|
+
try {
|
|
2696
|
+
result = await agent.run(task);
|
|
2697
|
+
} catch (err) {
|
|
2698
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2699
|
+
if (attempt < MAX_ATTEMPTS && RETRIABLE_RE.test(msg)) {
|
|
2700
|
+
const delay = BASE_DELAY_MS * attempt;
|
|
2701
|
+
process.stderr.write(`[Retry] Provider error on attempt ${attempt}/${MAX_ATTEMPTS}: ${msg.slice(0, 120)}\n[Retry] Waiting ${delay / 1e3}s before retrying...\n`);
|
|
2702
|
+
iterationOffset += lastSeenIteration;
|
|
2703
|
+
lastSeenIteration = 0;
|
|
2704
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
2705
|
+
continue;
|
|
2706
|
+
}
|
|
2707
|
+
throw err;
|
|
2708
|
+
}
|
|
2709
|
+
if (result.status !== "completed") {
|
|
2710
|
+
const err = result.error ?? /* @__PURE__ */ new Error(`Agent run ended with status "${result.status}" (model API error?)`);
|
|
2711
|
+
const msg = err.message;
|
|
2712
|
+
if (attempt < MAX_ATTEMPTS && RETRIABLE_RE.test(msg)) {
|
|
2713
|
+
const delay = BASE_DELAY_MS * attempt;
|
|
2714
|
+
process.stderr.write(`[Retry] Silent provider error (status=${result.status}) on attempt ${attempt}/${MAX_ATTEMPTS}: ${msg.slice(0, 120)}\n[Retry] Waiting ${delay / 1e3}s before retrying...\n`);
|
|
2715
|
+
iterationOffset += lastSeenIteration;
|
|
2716
|
+
lastSeenIteration = 0;
|
|
2717
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
2718
|
+
continue;
|
|
2719
|
+
}
|
|
2720
|
+
throw err;
|
|
2721
|
+
}
|
|
2722
|
+
return result;
|
|
2723
|
+
}
|
|
2724
|
+
throw new Error("unreachable");
|
|
2725
|
+
}
|
|
2726
|
+
try {
|
|
2727
|
+
const result = await runWithRetry();
|
|
2728
|
+
console.error(`\n=== Run complete ===\n`);
|
|
2729
|
+
if (result.outputText) console.log(result.outputText);
|
|
2730
|
+
else throw new Error("Agent run completed but generate_report was never called (empty output). Check the prompt log for details.");
|
|
2731
|
+
} finally {
|
|
2732
|
+
userInstructionService.stop();
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
main().then(() => process.exit(0)).catch((err) => {
|
|
2736
|
+
console.error("Fatal error:", err instanceof Error ? err.message : String(err));
|
|
2737
|
+
process.exit(1);
|
|
2738
|
+
});
|
|
2739
|
+
//#endregion
|
|
2740
|
+
|
|
2741
|
+
//# sourceMappingURL=main.mjs.map
|