@ipul-labs/forge 1.14.1 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/package.json +7 -4
- package/server/forge/core/ForgeAgent.js +39 -14
- package/server/forge/core/ForgeAgent.test.js +179 -0
- package/server/forge/core/claudeClient.js +25 -5
- package/server/forge/core/configStore.js +15 -0
- package/server/forge/core/configStore.test.js +89 -0
- package/server/forge/core/localRepo.js +4 -2
- package/server/forge/core/prompts.js +85 -15
- package/server/forge/core/prompts.test.js +219 -0
- package/server/forge/core/providers/azuredevops.js +4 -3
- package/server/forge/core/providers/github.js +3 -4
- package/server/forge/core/providers/gitlab.js +6 -6
- package/server/forge/core/validator/clone.js +3 -2
- package/server/forge/core/validator.js +3 -2
- package/server/forge/routes.js +7 -7
package/README.md
CHANGED
|
@@ -145,26 +145,28 @@ See [`docs/cli.md`](./docs/cli.md) for the full command reference and [`docs/cli
|
|
|
145
145
|
|
|
146
146
|
| Variable | Required | Description |
|
|
147
147
|
|---|---|---|
|
|
148
|
-
| `FORGE_TOKEN` | Yes | Dashboard password —
|
|
148
|
+
| `FORGE_TOKEN` | Yes | Dashboard password — also auto-fills the UI login form |
|
|
149
|
+
| `FORGE_REPOS` | Yes | Comma-separated repo list; first entry is the primary project |
|
|
149
150
|
| `SCM_PROVIDER` | — | `gitlab` (default) · `github` · `azuredevops` |
|
|
150
|
-
| `FORGE_REPOS` | — | Comma-separated repo list for multi-repo support |
|
|
151
151
|
| `ANTHROPIC_API_KEY` | — | Enables live model list from Anthropic; falls back to hardcoded list |
|
|
152
152
|
| `FORGE_MODEL` | — | Override default model (default: `claude-sonnet-4-6`) |
|
|
153
|
-
| `VITE_FORGE_TOKEN` | — | Baked into the UI — skips the token entry form on load |
|
|
154
153
|
| `K8S_CLUSTER_TOKEN` | — | K8s service account token for rollout monitoring |
|
|
155
154
|
| `K8S_CLUSTER_SERVER` | — | K8s API server URL |
|
|
156
155
|
| `PORT` | — | HTTP server port (default: `5001`) |
|
|
157
156
|
| `OPENCLAW_GATEWAY_URL` | — | Enables OpenClaw agent backend; point at your local Gateway |
|
|
158
157
|
| `OPENCLAW_TOKEN` | — | Bearer token for OpenClaw Gateway auth |
|
|
159
158
|
|
|
160
|
-
###
|
|
159
|
+
### Provider credentials
|
|
160
|
+
|
|
161
|
+
SCM tokens can be set here **or** in the Forge Settings UI (Settings → Connections). The UI value takes precedence over env vars.
|
|
162
|
+
|
|
163
|
+
#### GitLab (`SCM_PROVIDER=gitlab`, default)
|
|
161
164
|
|
|
162
165
|
| Variable | Required | Description |
|
|
163
166
|
|---|---|---|
|
|
164
|
-
| `FORGE_GITLAB_PROJECT` | Yes | Default project path, e.g. `group/repo` |
|
|
165
167
|
| `GITLAB_TOKEN` | Yes | Personal access token with `api` + `write_repository` scopes |
|
|
166
168
|
|
|
167
|
-
|
|
169
|
+
#### GitHub (`SCM_PROVIDER=github`)
|
|
168
170
|
|
|
169
171
|
| Variable | Required | Description |
|
|
170
172
|
|---|---|---|
|
|
@@ -173,7 +175,7 @@ See [`docs/cli.md`](./docs/cli.md) for the full command reference and [`docs/cli
|
|
|
173
175
|
| `GITHUB_REPO` | Yes | Repository name, e.g. `web-app` |
|
|
174
176
|
| `GITHUB_BASE_BRANCH` | — | Default target branch (default: `main`) |
|
|
175
177
|
|
|
176
|
-
|
|
178
|
+
#### Azure DevOps (`SCM_PROVIDER=azuredevops`)
|
|
177
179
|
|
|
178
180
|
| Variable | Required | Description |
|
|
179
181
|
|---|---|---|
|
|
@@ -183,6 +185,14 @@ See [`docs/cli.md`](./docs/cli.md) for the full command reference and [`docs/cli
|
|
|
183
185
|
| `AZURE_DEVOPS_REPO` | — | Git repo name (defaults to project name) |
|
|
184
186
|
| `AZURE_DEVOPS_BASE_BRANCH` | — | Default target branch (default: `main`) |
|
|
185
187
|
|
|
188
|
+
### Deprecated (still work as fallbacks)
|
|
189
|
+
|
|
190
|
+
| Variable | Replaced by |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `FORGE_GITLAB_PROJECT` | First entry in `FORGE_REPOS` |
|
|
193
|
+
| `VITE_FORGE_TOKEN` | Auto-derived from `FORGE_TOKEN` |
|
|
194
|
+
| `FORGE_REFINE_REPO_ACCESS` | Settings UI toggle (Settings → Defaults) |
|
|
195
|
+
|
|
186
196
|
See `.env.example` for a full annotated list.
|
|
187
197
|
|
|
188
198
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ipul-labs/forge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.1",
|
|
4
4
|
"description": "AI-powered code change & deploy control panel — connects Claude, GitLab, and Kubernetes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server/forge/routes.js",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"dev": "trap 'kill 0' EXIT INT TERM; FORGE_NO_VITE=1 node --watch-path=./server --watch-path=./server.js server.js & vite; wait",
|
|
22
22
|
"build": "vite build",
|
|
23
23
|
"start": "NODE_ENV=production node server.js",
|
|
24
|
-
"typecheck": "tsc --noEmit"
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest"
|
|
25
27
|
},
|
|
26
28
|
"dependencies": {
|
|
27
29
|
"@kubernetes/client-node": "^1.0.0",
|
|
@@ -47,13 +49,14 @@
|
|
|
47
49
|
"@types/express": "^5.0.0",
|
|
48
50
|
"@types/react": "^19.0.0",
|
|
49
51
|
"@types/react-dom": "^19.0.0",
|
|
50
|
-
"@vitejs/plugin-react": "^
|
|
52
|
+
"@vitejs/plugin-react": "^6.0.0",
|
|
51
53
|
"autoprefixer": "^10.4.20",
|
|
52
54
|
"postcss": "^8.4.47",
|
|
53
55
|
"semantic-release": "^25.0.3",
|
|
54
56
|
"tailwindcss": "^3.4.14",
|
|
55
57
|
"typescript": "^5.6.0",
|
|
56
|
-
"vite": "^
|
|
58
|
+
"vite": "^8.0.0",
|
|
59
|
+
"vitest": "^4.1.0"
|
|
57
60
|
},
|
|
58
61
|
"engines": {
|
|
59
62
|
"node": ">=20.0.0"
|
|
@@ -26,6 +26,7 @@ import { checkResolution, identifyRelevantFiles, proposeFix, refineOrAsk } from
|
|
|
26
26
|
import { branchNameFor } from "./branchName.js";
|
|
27
27
|
import * as configStore from "./configStore.js";
|
|
28
28
|
import { ensureWorkingTree, overlayChanges } from "./localRepo.js";
|
|
29
|
+
import { classifyIssue } from "./prompts.js";
|
|
29
30
|
import { createProvider } from "./providers/index.js";
|
|
30
31
|
import { reattributeChanges } from "./validator/attribute.js";
|
|
31
32
|
|
|
@@ -100,19 +101,26 @@ function isLikelyResumeFailure(err) {
|
|
|
100
101
|
*/
|
|
101
102
|
|
|
102
103
|
const MAX_FILE_BYTES = 40_000; // ~40 KB per file to keep the context window healthy
|
|
103
|
-
const MAX_FILES_TOTAL = 12; // hard cap across all repos
|
|
104
|
-
const MAX_FILES_PER_REPO = 8; // cap per repo so one repo can't starve another
|
|
105
104
|
const CONV_PREFIX = /^(?:feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)(?:\([^)]+\))?!?:\s*/i;
|
|
106
105
|
|
|
106
|
+
const FILE_CAPS = {
|
|
107
|
+
feature: { total: 18, perRepo: 15 },
|
|
108
|
+
refactor: { total: 18, perRepo: 15 },
|
|
109
|
+
fix: { total: 12, perRepo: 8 },
|
|
110
|
+
chore: { total: 12, perRepo: 8 },
|
|
111
|
+
};
|
|
112
|
+
|
|
107
113
|
/**
|
|
108
114
|
* Builds a conventional-commit subject line from the analysis. Prefers
|
|
109
115
|
* `analysis.mrTitle` (the model's own commit-style summary), falling back to
|
|
110
|
-
* the issue title with a
|
|
116
|
+
* the issue title with a prefix derived from the issue kind.
|
|
111
117
|
*/
|
|
112
118
|
function buildCommitSubject(analysis) {
|
|
113
119
|
const mrTitle = analysis.mrTitle?.trim();
|
|
114
120
|
if (mrTitle) return mrTitle;
|
|
115
|
-
|
|
121
|
+
const kind = classifyIssue(analysis.issue);
|
|
122
|
+
const prefix = kind === "feature" ? "feat" : kind === "refactor" ? "refactor" : "fix";
|
|
123
|
+
return `${prefix}: ${analysis.issue.title.replace(CONV_PREFIX, "")}`;
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
/**
|
|
@@ -194,7 +202,7 @@ export class ForgeAgent {
|
|
|
194
202
|
* @param {{ project?: string, secondaryProjects?: string[], agent?: string }} [opts]
|
|
195
203
|
*/
|
|
196
204
|
constructor(opts = {}) {
|
|
197
|
-
const primary = opts.project ??
|
|
205
|
+
const primary = opts.project ?? configStore.getPrimaryProject();
|
|
198
206
|
if (!primary) throw new Error("ForgeAgent requires a primary project.");
|
|
199
207
|
|
|
200
208
|
const secondaries = (opts.secondaryProjects ?? [])
|
|
@@ -235,7 +243,7 @@ export class ForgeAgent {
|
|
|
235
243
|
const results = await Promise.all(
|
|
236
244
|
this.allRepos.map(async (repo) => {
|
|
237
245
|
try {
|
|
238
|
-
const files = await this._provider(repo).getFileTree("
|
|
246
|
+
const files = await this._provider(repo).getFileTree("", "main");
|
|
239
247
|
return { repo, files };
|
|
240
248
|
} catch (err) {
|
|
241
249
|
emit({ type: "log", message: `Could not scan ${repo}: ${err.message}` });
|
|
@@ -315,13 +323,14 @@ export class ForgeAgent {
|
|
|
315
323
|
}
|
|
316
324
|
|
|
317
325
|
/** Enforces the per-repo and total caps, preserving model order. */
|
|
318
|
-
_capSelections(entries) {
|
|
326
|
+
_capSelections(entries, issueKind) {
|
|
327
|
+
const caps = FILE_CAPS[issueKind] ?? FILE_CAPS.fix;
|
|
319
328
|
const capped = [];
|
|
320
329
|
const perRepo = new Map();
|
|
321
330
|
for (const entry of entries) {
|
|
322
|
-
if (capped.length >=
|
|
331
|
+
if (capped.length >= caps.total) break;
|
|
323
332
|
const count = perRepo.get(entry.repo) ?? 0;
|
|
324
|
-
if (count >=
|
|
333
|
+
if (count >= caps.perRepo) continue;
|
|
325
334
|
capped.push(entry);
|
|
326
335
|
perRepo.set(entry.repo, count + 1);
|
|
327
336
|
}
|
|
@@ -391,9 +400,12 @@ export class ForgeAgent {
|
|
|
391
400
|
cacheReadTokens: cr1 = 0,
|
|
392
401
|
} = ident;
|
|
393
402
|
|
|
403
|
+
const issueKind = classifyIssue(issue);
|
|
404
|
+
emit({ type: "log", message: `Issue classified as: ${issueKind}` });
|
|
405
|
+
|
|
394
406
|
const normalized = this._normalizeFileSelections(rawFiles);
|
|
395
407
|
const { valid, skipped } = this._validateSelections(normalized, repoTrees);
|
|
396
|
-
const capped = this._capSelections(valid);
|
|
408
|
+
const capped = this._capSelections(valid, issueKind);
|
|
397
409
|
|
|
398
410
|
if (capped.length) {
|
|
399
411
|
emit({ type: "log", message: `Identified ${capped.length} file(s): ${capped.map((f) => `${f.repo}:${f.path}`).join(", ")}` });
|
|
@@ -413,10 +425,11 @@ export class ForgeAgent {
|
|
|
413
425
|
|
|
414
426
|
// Step 5 — propose fix (pass 2)
|
|
415
427
|
emit({ type: "progress", step: "generate_fix", message: msgs.generate_fix });
|
|
428
|
+
const promptRules = configStore.get().defaults?.promptRules || "";
|
|
416
429
|
const propose = await proposeFix(
|
|
417
430
|
issue,
|
|
418
431
|
fileContents,
|
|
419
|
-
{ ciContext, userPrompt, previousChanges, repos: this.allRepos },
|
|
432
|
+
{ ciContext, userPrompt, previousChanges, repos: this.allRepos, promptRules },
|
|
420
433
|
model,
|
|
421
434
|
this.agent,
|
|
422
435
|
{ signal },
|
|
@@ -425,6 +438,7 @@ export class ForgeAgent {
|
|
|
425
438
|
title: mrTitle,
|
|
426
439
|
explanation,
|
|
427
440
|
changes,
|
|
441
|
+
removals = [],
|
|
428
442
|
cost: cost2 = 0,
|
|
429
443
|
inputTokens: it2 = 0,
|
|
430
444
|
outputTokens: ot2 = 0,
|
|
@@ -438,6 +452,12 @@ export class ForgeAgent {
|
|
|
438
452
|
type: "log",
|
|
439
453
|
message: `Proposed ${enrichedChanges.length} change(s): ${enrichedChanges.map((c) => `${c.repo}:${c.file} (${c.action})`).join(", ")}`,
|
|
440
454
|
});
|
|
455
|
+
if (removals.length > 0) {
|
|
456
|
+
emit({
|
|
457
|
+
type: "log",
|
|
458
|
+
message: `⚠ ${removals.length} removal(s) declared: ${removals.map((r) => `${r.file}: ${r.what}`).join("; ")}`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
441
461
|
|
|
442
462
|
const result = {
|
|
443
463
|
issue,
|
|
@@ -445,6 +465,7 @@ export class ForgeAgent {
|
|
|
445
465
|
explanation,
|
|
446
466
|
reasoning,
|
|
447
467
|
changes: enrichedChanges,
|
|
468
|
+
removals,
|
|
448
469
|
repos: this.allRepos,
|
|
449
470
|
};
|
|
450
471
|
emit({ type: "result", data: result });
|
|
@@ -506,10 +527,11 @@ export class ForgeAgent {
|
|
|
506
527
|
// the caller expects fresh changes.
|
|
507
528
|
if (ciContext) {
|
|
508
529
|
emit({ type: "progress", step: "generate_fix", message: `Fixing CI failure with ${this._agentLabel}...` });
|
|
530
|
+
const ciRefineRules = configStore.get().defaults?.promptRules || "";
|
|
509
531
|
const propose = await proposeFix(
|
|
510
532
|
issue,
|
|
511
533
|
fileContents,
|
|
512
|
-
{ feedback, previousChanges, ciContext, repos: this.allRepos },
|
|
534
|
+
{ feedback, previousChanges, ciContext, repos: this.allRepos, promptRules: ciRefineRules },
|
|
513
535
|
model,
|
|
514
536
|
this.agent,
|
|
515
537
|
{ signal },
|
|
@@ -567,6 +589,7 @@ export class ForgeAgent {
|
|
|
567
589
|
emit({ type: "progress", step: "generate_fix", message: `Refining fix with ${this._agentLabel}...` });
|
|
568
590
|
if (sessionId) emit({ type: "session", sessionId, resumed: resumeSession });
|
|
569
591
|
|
|
592
|
+
const refinePromptRules = configStore.get().defaults?.promptRules || "";
|
|
570
593
|
const callRefine = (sid, resume) =>
|
|
571
594
|
refineOrAsk(
|
|
572
595
|
issue,
|
|
@@ -579,6 +602,7 @@ export class ForgeAgent {
|
|
|
579
602
|
repoCwd,
|
|
580
603
|
repoTrees,
|
|
581
604
|
repoAccessMode: repoCwd ? "tools" : "tree",
|
|
605
|
+
promptRules: refinePromptRules,
|
|
582
606
|
...(sid ? { sessionId: sid, resumeSession: resume } : {}),
|
|
583
607
|
},
|
|
584
608
|
model,
|
|
@@ -735,7 +759,7 @@ export class ForgeAgent {
|
|
|
735
759
|
} = ident;
|
|
736
760
|
|
|
737
761
|
const { valid } = this._validateSelections(this._normalizeFileSelections(rawFiles), repoTrees);
|
|
738
|
-
const capped = this._capSelections(valid);
|
|
762
|
+
const capped = this._capSelections(valid, "fix");
|
|
739
763
|
|
|
740
764
|
emit({
|
|
741
765
|
type: "progress",
|
|
@@ -745,10 +769,11 @@ export class ForgeAgent {
|
|
|
745
769
|
const fileContents = await this._fetchFiles(capped, emit);
|
|
746
770
|
|
|
747
771
|
emit({ type: "progress", step: "generate_fix", message: `Fixing CI failure with ${this._agentLabel}...` });
|
|
772
|
+
const ciPromptRules = configStore.get().defaults?.promptRules || "";
|
|
748
773
|
const propose = await proposeFix(
|
|
749
774
|
issue,
|
|
750
775
|
fileContents,
|
|
751
|
-
{ ciContext, repos: this.allRepos },
|
|
776
|
+
{ ciContext, repos: this.allRepos, promptRules: ciPromptRules },
|
|
752
777
|
model,
|
|
753
778
|
this.agent,
|
|
754
779
|
{ signal },
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// We test the module-level helpers and ForgeAgent internals by importing the
|
|
4
|
+
// module. The class constructor requires a project, so we mock the provider.
|
|
5
|
+
// For the pure functions (buildCommitSubject, FILE_CAPS), we test them through
|
|
6
|
+
// a thin wrapper or by constructing a ForgeAgent with mocked providers.
|
|
7
|
+
|
|
8
|
+
// ─── Module-level helpers ────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
// buildCommitSubject and FILE_CAPS are not exported, so we test them through
|
|
11
|
+
// ForgeAgent's public surface or by importing the module and inspecting.
|
|
12
|
+
// Since they're internal, we test via the class methods that use them.
|
|
13
|
+
|
|
14
|
+
import * as configStore from "./configStore.js";
|
|
15
|
+
|
|
16
|
+
// Mock configStore.getPrimaryProject for ForgeAgent constructor
|
|
17
|
+
vi.spyOn(configStore, "getPrimaryProject").mockReturnValue("group/web-app");
|
|
18
|
+
vi.spyOn(configStore, "get").mockReturnValue({
|
|
19
|
+
repos: [{ id: "group/web-app", label: "web-app" }],
|
|
20
|
+
defaults: { promptRules: "" },
|
|
21
|
+
connections: {},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Mock the provider factory
|
|
25
|
+
vi.mock("./providers/index.js", () => ({
|
|
26
|
+
createProvider: () => ({
|
|
27
|
+
getIssue: vi.fn(),
|
|
28
|
+
getFileTree: vi.fn().mockResolvedValue([]),
|
|
29
|
+
getFileContent: vi.fn(),
|
|
30
|
+
createBranch: vi.fn(),
|
|
31
|
+
commitFiles: vi.fn(),
|
|
32
|
+
createMR: vi.fn(),
|
|
33
|
+
listAllForgeMRs: vi.fn().mockResolvedValue([]),
|
|
34
|
+
getMRPipelines: vi.fn().mockResolvedValue([]),
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Dynamic import after mocks are set up
|
|
39
|
+
const { ForgeAgent } = await import("./ForgeAgent.js");
|
|
40
|
+
|
|
41
|
+
describe("ForgeAgent", () => {
|
|
42
|
+
describe("_capSelections", () => {
|
|
43
|
+
let agent;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
agent = new ForgeAgent({ project: "group/web-app" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("applies fix caps (12 total, 8 per repo)", () => {
|
|
50
|
+
const entries = Array.from({ length: 15 }, (_, i) => ({
|
|
51
|
+
repo: "group/web-app",
|
|
52
|
+
path: `src/file${i}.ts`,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const capped = agent._capSelections(entries, "fix");
|
|
56
|
+
expect(capped).toHaveLength(8);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("applies feature caps (18 total, 15 per repo)", () => {
|
|
60
|
+
const entries = Array.from({ length: 20 }, (_, i) => ({
|
|
61
|
+
repo: "group/web-app",
|
|
62
|
+
path: `src/file${i}.ts`,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const capped = agent._capSelections(entries, "feature");
|
|
66
|
+
expect(capped).toHaveLength(15);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("enforces total cap across multiple repos for features", () => {
|
|
70
|
+
const entries = [
|
|
71
|
+
...Array.from({ length: 12 }, (_, i) => ({
|
|
72
|
+
repo: "group/web-app",
|
|
73
|
+
path: `src/file${i}.ts`,
|
|
74
|
+
})),
|
|
75
|
+
...Array.from({ length: 12 }, (_, i) => ({
|
|
76
|
+
repo: "group/api",
|
|
77
|
+
path: `internal/file${i}.go`,
|
|
78
|
+
})),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const capped = agent._capSelections(entries, "feature");
|
|
82
|
+
expect(capped).toHaveLength(18);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("defaults to fix caps for unknown kinds", () => {
|
|
86
|
+
const entries = Array.from({ length: 15 }, (_, i) => ({
|
|
87
|
+
repo: "group/web-app",
|
|
88
|
+
path: `src/file${i}.ts`,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
const capped = agent._capSelections(entries, "unknown");
|
|
92
|
+
expect(capped).toHaveLength(8);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("refactor gets same caps as feature", () => {
|
|
96
|
+
const entries = Array.from({ length: 20 }, (_, i) => ({
|
|
97
|
+
repo: "group/web-app",
|
|
98
|
+
path: `src/file${i}.ts`,
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
const capped = agent._capSelections(entries, "refactor");
|
|
102
|
+
expect(capped).toHaveLength(15);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("_normalizeFileSelections", () => {
|
|
107
|
+
let agent;
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
agent = new ForgeAgent({ project: "group/web-app" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("normalizes string entries to primary repo", () => {
|
|
114
|
+
const result = agent._normalizeFileSelections(["src/App.tsx"]);
|
|
115
|
+
expect(result).toEqual([{ repo: "group/web-app", path: "src/App.tsx" }]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("passes through {repo, path} objects", () => {
|
|
119
|
+
const result = agent._normalizeFileSelections([
|
|
120
|
+
{ repo: "group/api", path: "main.go" },
|
|
121
|
+
]);
|
|
122
|
+
expect(result).toEqual([{ repo: "group/api", path: "main.go" }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("defaults repo when only path is present", () => {
|
|
126
|
+
const result = agent._normalizeFileSelections([{ path: "src/a.ts" }]);
|
|
127
|
+
expect(result).toEqual([{ repo: "group/web-app", path: "src/a.ts" }]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("filters out null and invalid entries", () => {
|
|
131
|
+
const result = agent._normalizeFileSelections([null, undefined, {}, "src/a.ts"]);
|
|
132
|
+
expect(result).toHaveLength(1);
|
|
133
|
+
expect(result[0].path).toBe("src/a.ts");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("_validateSelections", () => {
|
|
138
|
+
let agent;
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
agent = new ForgeAgent({ project: "group/web-app" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("keeps files that exist in the tree", () => {
|
|
145
|
+
const entries = [{ repo: "group/web-app", path: "src/App.tsx" }];
|
|
146
|
+
const trees = [{ repo: "group/web-app", files: ["src/App.tsx", "src/index.ts"] }];
|
|
147
|
+
const { valid, skipped } = agent._validateSelections(entries, trees);
|
|
148
|
+
expect(valid).toHaveLength(1);
|
|
149
|
+
expect(skipped).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("skips files not in the tree", () => {
|
|
153
|
+
const entries = [{ repo: "group/web-app", path: "src/Missing.tsx" }];
|
|
154
|
+
const trees = [{ repo: "group/web-app", files: ["src/App.tsx"] }];
|
|
155
|
+
const { valid, skipped } = agent._validateSelections(entries, trees);
|
|
156
|
+
expect(valid).toHaveLength(0);
|
|
157
|
+
expect(skipped).toHaveLength(1);
|
|
158
|
+
expect(skipped[0]).toContain("not in tree");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("skips files from unknown repos", () => {
|
|
162
|
+
const entries = [{ repo: "group/unknown", path: "src/a.ts" }];
|
|
163
|
+
const trees = [{ repo: "group/web-app", files: ["src/a.ts"] }];
|
|
164
|
+
const { valid, skipped } = agent._validateSelections(entries, trees);
|
|
165
|
+
expect(valid).toHaveLength(0);
|
|
166
|
+
expect(skipped[0]).toContain("unknown repo");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("deduplicates entries", () => {
|
|
170
|
+
const entries = [
|
|
171
|
+
{ repo: "group/web-app", path: "src/App.tsx" },
|
|
172
|
+
{ repo: "group/web-app", path: "src/App.tsx" },
|
|
173
|
+
];
|
|
174
|
+
const trees = [{ repo: "group/web-app", files: ["src/App.tsx"] }];
|
|
175
|
+
const { valid } = agent._validateSelections(entries, trees);
|
|
176
|
+
expect(valid).toHaveLength(1);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
buildRefineOrAskPrompt,
|
|
23
23
|
buildRefineResumePrompt,
|
|
24
24
|
buildCheckResolutionPrompt,
|
|
25
|
+
classifyIssue,
|
|
25
26
|
} from "./prompts.js";
|
|
26
27
|
|
|
27
28
|
// FORGE_MODEL env var overrides the default. Set to a cheaper model to reduce cost.
|
|
@@ -334,10 +335,24 @@ Your final message MUST be a single raw JSON object matching the schema — no p
|
|
|
334
335
|
|
|
335
336
|
export async function proposeFix(issue, fileContents, options = {}, model, opts = {}) {
|
|
336
337
|
const schema = options.ciContext ? CI_FIX_SCHEMA : PROPOSE_FIX_SCHEMA;
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
338
|
+
const kind = classifyIssue(issue);
|
|
339
|
+
const isFeature = kind === "feature" || kind === "refactor";
|
|
340
|
+
|
|
341
|
+
const systemPrompt = isFeature
|
|
342
|
+
? `You are Forge, an AI code assistant in a production React/TypeScript/Express codebase.
|
|
343
|
+
Rules:
|
|
344
|
+
- Match the existing code style exactly (TypeScript strict, no barrel imports, Tailwind for UI).
|
|
345
|
+
- For "proposed", always return the COMPLETE file content, not a diff or snippet.
|
|
346
|
+
- Maximum 12 files changed. Prioritise core changes over secondary ones — if the feature touches more files than the cap, implement the most important changes and note the rest in "explanation".
|
|
347
|
+
- Never add console.* — use the existing log utility.
|
|
348
|
+
- Never add comments unless the logic is non-obvious.
|
|
349
|
+
- Before returning, verify TypeScript correctness: no implicit any, no missing imports, no type mismatches, all imported symbols must actually be exported from their source.
|
|
350
|
+
Feature discipline:
|
|
351
|
+
- Follow the issue specification. Only make changes the issue asks for — do not clean up, refactor, or "improve" unrelated code.
|
|
352
|
+
- When the issue says to ADD an entry to an existing list or config (nav items, routes, type unions), add the entry — do not restructure the existing code around it.
|
|
353
|
+
- Base new files on the patterns and conventions visible in the relevant files.
|
|
354
|
+
- Use a "feat:" conventional-commit prefix in the title, not "fix:".`
|
|
355
|
+
: `You are Forge, an AI code assistant in a production React/TypeScript/Express codebase.
|
|
341
356
|
Rules:
|
|
342
357
|
- Changes must be minimal and surgical.
|
|
343
358
|
- Match the existing code style exactly (TypeScript strict, no barrel imports, Tailwind for UI).
|
|
@@ -350,7 +365,12 @@ Rules:
|
|
|
350
365
|
Root-cause discipline:
|
|
351
366
|
- A fix that only improves an error message or adds a guard at the point of failure is a symptom suppressor, not a fix. Reject it and find the upstream origin of the bad input.
|
|
352
367
|
- When a value is undefined/null at a check, trace one or two frames up (the caller of the function, the prop passed into the component, the handler bound to an onClick). Default function parameters are defeated when the caller passes an unexpected argument (e.g. React forwarding a MouseEvent) — look for that class of bug before patching the consumer.
|
|
353
|
-
- In "explanation", name the origin of the bad value and state why the reported symptom can no longer occur after your change
|
|
368
|
+
- In "explanation", name the origin of the bad value and state why the reported symptom can no longer occur after your change.`;
|
|
369
|
+
|
|
370
|
+
const { data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens } = await runClaude(
|
|
371
|
+
buildProposeFixPrompt(issue, fileContents, options),
|
|
372
|
+
schema,
|
|
373
|
+
systemPrompt,
|
|
354
374
|
model,
|
|
355
375
|
{ signal: opts.signal },
|
|
356
376
|
);
|
|
@@ -30,6 +30,7 @@ import path from "node:path";
|
|
|
30
30
|
* agent: string | null,
|
|
31
31
|
* autoPushOnValidatePass: boolean,
|
|
32
32
|
* refineRepoAccess: boolean,
|
|
33
|
+
* promptRules: string,
|
|
33
34
|
* },
|
|
34
35
|
* connections: {
|
|
35
36
|
* gitlab: GitLabConnection,
|
|
@@ -55,6 +56,9 @@ const DEFAULT_CONFIG = {
|
|
|
55
56
|
// clone of the primary repo. Off → lighter "file tree in prompt" flow.
|
|
56
57
|
// The FORGE_REFINE_REPO_ACCESS env var, when set, overrides this.
|
|
57
58
|
refineRepoAccess: true,
|
|
59
|
+
// Free-form rules injected into every analysis and refine prompt.
|
|
60
|
+
// Lets the user steer the model without touching code.
|
|
61
|
+
promptRules: "",
|
|
58
62
|
// Test-deploy URL surfaced next to "Approve & Create MR" when the
|
|
59
63
|
// branch pipeline is green. Plain URL (e.g. "https://test.example.com")
|
|
60
64
|
// for a shared env; supports "{branch}" / "{slug}" placeholders when
|
|
@@ -153,6 +157,17 @@ export function reload() {
|
|
|
153
157
|
return cache;
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Resolves the primary project identifier.
|
|
162
|
+
* Priority: persisted config repos[0] → FORGE_REPOS first entry → FORGE_GITLAB_PROJECT.
|
|
163
|
+
*/
|
|
164
|
+
export function getPrimaryProject() {
|
|
165
|
+
const repos = cache.repos;
|
|
166
|
+
if (repos.length > 0) return repos[0].id;
|
|
167
|
+
const raw = process.env.FORGE_REPOS ?? process.env.FORGE_GITLAB_PROJECT ?? "";
|
|
168
|
+
return raw.split(",")[0]?.trim() || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
const ENV_TOKEN_KEYS = {
|
|
157
172
|
gitlab: "GITLAB_TOKEN",
|
|
158
173
|
github: "GITHUB_TOKEN",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// We test getPrimaryProject and getConnection which resolve from
|
|
4
|
+
// cache (configStore state) → env vars → defaults.
|
|
5
|
+
|
|
6
|
+
import { getPrimaryProject, getConnection, get, reload } from "./configStore.js";
|
|
7
|
+
|
|
8
|
+
describe("getPrimaryProject", () => {
|
|
9
|
+
const originalEnv = { ...process.env };
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
process.env = { ...originalEnv };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns first config repo when config has repos", () => {
|
|
16
|
+
const cfg = get();
|
|
17
|
+
// If the test env has repos in config, this tests the real path.
|
|
18
|
+
// Otherwise we test the env fallback paths below.
|
|
19
|
+
if (cfg.repos.length > 0) {
|
|
20
|
+
expect(getPrimaryProject()).toBe(cfg.repos[0].id);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("falls back to FORGE_REPOS first entry", () => {
|
|
25
|
+
// Temporarily clear config repos by checking current state
|
|
26
|
+
const cfg = get();
|
|
27
|
+
if (cfg.repos.length === 0) {
|
|
28
|
+
process.env.FORGE_REPOS = "group/web-app,group/api";
|
|
29
|
+
expect(getPrimaryProject()).toBe("group/web-app");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("falls back to FORGE_GITLAB_PROJECT when FORGE_REPOS is unset", () => {
|
|
34
|
+
const cfg = get();
|
|
35
|
+
if (cfg.repos.length === 0) {
|
|
36
|
+
delete process.env.FORGE_REPOS;
|
|
37
|
+
process.env.FORGE_GITLAB_PROJECT = "legacy/project";
|
|
38
|
+
expect(getPrimaryProject()).toBe("legacy/project");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns null when nothing is configured", () => {
|
|
43
|
+
const cfg = get();
|
|
44
|
+
if (cfg.repos.length === 0) {
|
|
45
|
+
delete process.env.FORGE_REPOS;
|
|
46
|
+
delete process.env.FORGE_GITLAB_PROJECT;
|
|
47
|
+
expect(getPrimaryProject()).toBeNull();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("trims whitespace from FORGE_REPOS entries", () => {
|
|
52
|
+
const cfg = get();
|
|
53
|
+
if (cfg.repos.length === 0) {
|
|
54
|
+
process.env.FORGE_REPOS = " group/web-app , group/api ";
|
|
55
|
+
expect(getPrimaryProject()).toBe("group/web-app");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("getConnection", () => {
|
|
61
|
+
const originalEnv = { ...process.env };
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
process.env = { ...originalEnv };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns token from env when config has none", () => {
|
|
68
|
+
process.env.GITLAB_TOKEN = "test-token-123";
|
|
69
|
+
const conn = getConnection("gitlab");
|
|
70
|
+
// Config token takes precedence, so if config has one we get that.
|
|
71
|
+
// Either way, we should get a non-empty token.
|
|
72
|
+
expect(conn.token).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns a baseUrl for gitlab", () => {
|
|
76
|
+
const conn = getConnection("gitlab");
|
|
77
|
+
expect(conn.baseUrl).toContain("gitlab.com");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns empty token when nothing is configured", () => {
|
|
81
|
+
delete process.env.GITLAB_TOKEN;
|
|
82
|
+
const cfg = get();
|
|
83
|
+
// Only test if config also has no token
|
|
84
|
+
if (!cfg.connections?.gitlab?.token) {
|
|
85
|
+
const conn = getConnection("gitlab");
|
|
86
|
+
expect(conn.token).toBe("");
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -18,6 +18,8 @@ import fs from "fs/promises";
|
|
|
18
18
|
import os from "os";
|
|
19
19
|
import path from "path";
|
|
20
20
|
|
|
21
|
+
import { getConnection } from "./configStore.js";
|
|
22
|
+
|
|
21
23
|
const CACHE_ROOT = process.env.FORGE_VALIDATE_CACHE
|
|
22
24
|
?? path.join(os.homedir(), ".forge", "cache");
|
|
23
25
|
|
|
@@ -53,8 +55,8 @@ function sanitizeRepo(repo) {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
function gitlabCloneUrl(project) {
|
|
56
|
-
const token =
|
|
57
|
-
if (!token) throw new Error("
|
|
58
|
+
const { token } = getConnection("gitlab");
|
|
59
|
+
if (!token) throw new Error("GitLab token is required to clone the repo locally. Set it in Settings → Connections or via GITLAB_TOKEN env var.");
|
|
58
60
|
return `https://oauth2:${token}@gitlab.com/${project}.git`;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -148,6 +148,30 @@ export const RESOLUTION_CHECK_SCHEMA = JSON.stringify({
|
|
|
148
148
|
required: ["resolved", "reasoning"],
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Classifies an issue as "feature", "fix", or "chore" from its title, labels,
|
|
153
|
+
* and description. Used to select the right prompt guidance and file caps.
|
|
154
|
+
*
|
|
155
|
+
* Priority: conventional-commit title prefix > labels > description heuristics > default "fix".
|
|
156
|
+
*/
|
|
157
|
+
export function classifyIssue(issue) {
|
|
158
|
+
const title = (issue.title ?? "").toLowerCase();
|
|
159
|
+
const labels = (issue.labels ?? []).map((l) => (typeof l === "string" ? l : l.name ?? "").toLowerCase());
|
|
160
|
+
|
|
161
|
+
if (/^feat[\s(:/!]/.test(title)) return "feature";
|
|
162
|
+
if (/^fix[\s(:/!]/.test(title)) return "fix";
|
|
163
|
+
if (/^chore[\s(:/!]/.test(title)) return "chore";
|
|
164
|
+
if (/^refactor[\s(:/!]/.test(title)) return "refactor";
|
|
165
|
+
|
|
166
|
+
if (labels.some((l) => l.includes("feature") || l.includes("enhancement"))) return "feature";
|
|
167
|
+
if (labels.some((l) => l.includes("bug") || l.includes("defect"))) return "fix";
|
|
168
|
+
|
|
169
|
+
const desc = (issue.description ?? "").toLowerCase();
|
|
170
|
+
if (desc.includes("## approach") || desc.includes("## frontend changes") || desc.includes("## backend")) return "feature";
|
|
171
|
+
|
|
172
|
+
return "fix";
|
|
173
|
+
}
|
|
174
|
+
|
|
151
175
|
/**
|
|
152
176
|
* Builds a human-readable block listing each repo's file tree.
|
|
153
177
|
* Accepts either:
|
|
@@ -198,17 +222,32 @@ export function buildIdentifyFilesPrompt(issue, repoTrees) {
|
|
|
198
222
|
? `\n\n## Multi-repo guidance\nThese repositories are deployed together as a full-stack application. If the issue genuinely requires coordinated changes in more than one repo (e.g. a new API endpoint in the backend AND its client in the frontend), select files from each affected repo. If the fix is self-contained, pick files from only one repo.`
|
|
199
223
|
: "";
|
|
200
224
|
|
|
225
|
+
const kind = classifyIssue(issue);
|
|
226
|
+
const isFeature = kind === "feature" || kind === "refactor";
|
|
227
|
+
|
|
228
|
+
const selectionRules = isFeature
|
|
229
|
+
? `Selection rules (feature implementation):
|
|
230
|
+
- Focus on files that ALREADY EXIST and need modification to support the new feature.
|
|
231
|
+
- Include files that serve as PATTERNS for new files the issue asks you to create (e.g. an existing page component when a new page route is needed, an existing hook when a new hook is needed).
|
|
232
|
+
- Include shared registries and type files that need new entries (routes, nav items, type definitions, API endpoints).
|
|
233
|
+
- When the issue specifies exact file paths to modify, prefer those paths.
|
|
234
|
+
- Do NOT select files for changes the issue doesn't mention — follow the spec, don't invent work.
|
|
235
|
+
- If the issue references an existing UI element (e.g. sidebar, navbar) and only asks to ADD an entry, select the data/config file that drives that element, not the rendering component (unless the issue explicitly says to change rendering logic).`
|
|
236
|
+
: `Selection rules (bug fix):
|
|
237
|
+
- When a bug points at a failing assertion, guard, or validation (e.g. "undefined priceId", "X is not defined", "cannot read property of undefined"), include the IMMEDIATE CALLER(S) of that function — not just the file where the error is thrown. The bad value usually originates upstream.
|
|
238
|
+
- When a bug points at an event handler or React component, include the component that renders the handler AND the handler's implementation.
|
|
239
|
+
- Prefer fewer files over more, but never omit a caller when the reported symptom is a downstream consequence of input received from that caller.`;
|
|
240
|
+
|
|
241
|
+
const maxFiles = isFeature ? 15 : 10;
|
|
242
|
+
|
|
201
243
|
return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
|
|
202
244
|
|
|
203
245
|
Schema: {"files": [{"repo": "<repo id>", "path": "src/foo/bar.ts"}], "reasoning": "one or two sentences"}
|
|
204
246
|
|
|
205
|
-
Identify the minimum set of source files (max
|
|
247
|
+
Identify the ${isFeature ? "set" : "minimum set"} of source files (max ${maxFiles} across all repos) relevant to ${isFeature ? "implementing" : "fixing"} this issue.
|
|
206
248
|
Always use an exact repo id from the "Available repositories" list below — do not invent or abbreviate.
|
|
207
249
|
|
|
208
|
-
|
|
209
|
-
- When a bug points at a failing assertion, guard, or validation (e.g. "undefined priceId", "X is not defined", "cannot read property of undefined"), include the IMMEDIATE CALLER(S) of that function — not just the file where the error is thrown. The bad value usually originates upstream.
|
|
210
|
-
- When a bug points at an event handler or React component, include the component that renders the handler AND the handler's implementation.
|
|
211
|
-
- Prefer fewer files over more, but never omit a caller when the reported symptom is a downstream consequence of input received from that caller.
|
|
250
|
+
${selectionRules}
|
|
212
251
|
|
|
213
252
|
## Issue #${issue.iid}: ${issue.title}
|
|
214
253
|
${issue.description || "(no description)"}
|
|
@@ -222,9 +261,11 @@ ${renderRepoTrees(repoTrees)}`;
|
|
|
222
261
|
}
|
|
223
262
|
|
|
224
263
|
export function buildProposeFixPrompt(issue, fileContents, options = {}) {
|
|
225
|
-
const { feedback, previousChanges, ciContext, userPrompt, repos } = options;
|
|
264
|
+
const { feedback, previousChanges, ciContext, userPrompt, repos, promptRules } = options;
|
|
226
265
|
|
|
227
266
|
const filesBlock = renderFileContents(fileContents);
|
|
267
|
+
const kind = classifyIssue(issue);
|
|
268
|
+
const isFeature = kind === "feature" || kind === "refactor";
|
|
228
269
|
|
|
229
270
|
const previousBlock = previousChanges?.length
|
|
230
271
|
? `\n\n## Previous proposed changes\n${renderPreviousChanges(previousChanges)}`
|
|
@@ -257,21 +298,46 @@ ${ciContext.trim()}`
|
|
|
257
298
|
? `\n\n## Multi-repo guidance\nThis is a full-stack change. If the fix requires coordinated updates in more than one repo, produce changes tagged with the correct repo for each. Make sure API shapes, types, and field names agree across repos.`
|
|
258
299
|
: "";
|
|
259
300
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
Propose a targeted fix for this issue.
|
|
301
|
+
const rulesBlock = promptRules?.trim()
|
|
302
|
+
? `\n\n## Custom rules (from project settings — always follow these)\n${promptRules.trim()}`
|
|
303
|
+
: "";
|
|
265
304
|
|
|
266
|
-
|
|
305
|
+
const titlePrefix = isFeature ? "feat" : "fix";
|
|
306
|
+
const titleExample = isFeature
|
|
307
|
+
? `"feat: add browseable mixes section with platinum-gated publishing"`
|
|
308
|
+
: `"fix: resolve token refresh race condition in AuthService"`;
|
|
309
|
+
|
|
310
|
+
const collateralGuard = `COLLATERAL DAMAGE GUARD — read BEFORE writing any "proposed" content:
|
|
311
|
+
Your default when modifying a file is to PRESERVE every existing line that is not directly related to the issue. Do not silently remove, reorganize, or simplify wrapper elements, imports, sibling components, or unrelated UI features (version displays, badges, icons, etc.).
|
|
312
|
+
If you believe existing code MUST be removed or restructured for the change to work, you MUST declare each removal in the top-level "removals" array. Each entry needs: the file path, a short description of what was removed, and why the removal is necessary. An empty "removals" array (or omitting it) means "I preserved everything unrelated."
|
|
313
|
+
The reviewer will see these as warnings and must acknowledge them before the change can be committed — so never silently drop existing functionality.`;
|
|
314
|
+
|
|
315
|
+
const discipline = isFeature
|
|
316
|
+
? `Feature implementation rules (read BEFORE writing any change):
|
|
317
|
+
1. Follow the issue specification precisely. If the issue says to modify a specific file, modify that file. If it says to create a new file, create it. Do NOT invent changes the issue doesn't ask for.
|
|
318
|
+
2. When the issue references existing components or patterns to reuse, base your implementation on the existing patterns in the "Relevant files" section.
|
|
319
|
+
3. For new files, follow the naming conventions, import style, and structure of the existing codebase.
|
|
320
|
+
4. Be aware of existing routes, tabs, and UI sections that may OVERLAP with what you're implementing. If the issue creates a new "/mixes" route, check whether the codebase already has mixes-related UI elsewhere (e.g. a tab inside another page) and note any conflict in "explanation".
|
|
321
|
+
5. In your "explanation", state (a) which existing patterns you followed, (b) what new files you created, and (c) any conflicts or considerations the reviewer should know about.
|
|
322
|
+
6. ${collateralGuard}`
|
|
323
|
+
: `Root-cause discipline (read BEFORE writing any change):
|
|
267
324
|
1. Identify the ORIGIN of the bad value, not just where it's observed. If a guard, assertion, or validation is failing, trace the value one or two frames up and name the caller that produced the bad input.
|
|
268
325
|
2. A fix that only makes an error message clearer (e.g. replacing \`assertIsDefined(x)\` with \`if (!x) toast.error(…)\`) is NOT a fix — it is a symptom suppressor. Reject it and propose the upstream fix instead.
|
|
269
326
|
3. In your "explanation", state (a) where the bad value originates, (b) why the fix closes the reported symptom end-to-end, and (c) what would have to be true for the reported error to still appear after your change.
|
|
270
327
|
4. If the origin is in a file that was not selected in pass 1, your "changes" should include that file. Do not constrain yourself to only the files listed under "Relevant files" when doing so would force a symptom-level patch.
|
|
328
|
+
5. ${collateralGuard}`;
|
|
329
|
+
|
|
330
|
+
return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
|
|
331
|
+
|
|
332
|
+
Schema: {"title": "${titlePrefix}: concise description of the actual change (max 72 chars)", "explanation": "...", "changes": [{"repo": "<repo id>", "file": "...", "action": "modify|create|delete", "proposed": "<full file content>", "summary": "..."}], "removals": [{"file": "...", "what": "short description of removed code", "why": "reason this removal is necessary"}]}
|
|
333
|
+
|
|
334
|
+
${isFeature ? "Implement this feature." : "Propose a targeted fix for this issue."}
|
|
335
|
+
|
|
336
|
+
${discipline}
|
|
271
337
|
|
|
272
338
|
## Issue #${issue.iid}: ${issue.title}
|
|
273
339
|
${issue.description || "(no description)"}
|
|
274
|
-
Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${multiNote}${previousBlock}${feedbackBlock}${ciBlock}${userBlock}
|
|
340
|
+
Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${multiNote}${rulesBlock}${previousBlock}${feedbackBlock}${ciBlock}${userBlock}
|
|
275
341
|
|
|
276
342
|
## Relevant files
|
|
277
343
|
${filesBlock}`;
|
|
@@ -308,7 +374,7 @@ function renderValidationState(state) {
|
|
|
308
374
|
* responds with EITHER a grounded answer OR a fresh changes[] payload.
|
|
309
375
|
*/
|
|
310
376
|
export function buildRefineOrAskPrompt(issue, fileContents, options = {}) {
|
|
311
|
-
const { feedback, previousChanges, validationState, repos, repoTrees, repoAccessMode } = options;
|
|
377
|
+
const { feedback, previousChanges, validationState, repos, repoTrees, repoAccessMode, promptRules } = options;
|
|
312
378
|
|
|
313
379
|
const filesBlock = renderFileContents(fileContents);
|
|
314
380
|
const previousBlock = previousChanges?.length
|
|
@@ -327,6 +393,10 @@ export function buildRefineOrAskPrompt(issue, fileContents, options = {}) {
|
|
|
327
393
|
? `\n\n## Repo access\nThe current working directory is a checkout of the primary repo with the proposed changes already overlaid. Use the Read, Glob, and Grep tools to inspect any file you need before answering or refining. Do NOT claim you lack repo access.`
|
|
328
394
|
: `\n\n## Repo access\nYou have the full file tree above and the complete content of every file currently in scope. Treat these as authoritative. If you need a file not attached, name the path and ask the user to share it — do NOT claim you lack repo access.`;
|
|
329
395
|
|
|
396
|
+
const rulesBlock = promptRules?.trim()
|
|
397
|
+
? `\n\n## Custom rules (from project settings — always follow these)\n${promptRules.trim()}`
|
|
398
|
+
: "";
|
|
399
|
+
|
|
330
400
|
return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
|
|
331
401
|
|
|
332
402
|
Schema: {
|
|
@@ -356,7 +426,7 @@ If the message is ambiguous, prefer "answer" and ask a clarifying question.
|
|
|
356
426
|
|
|
357
427
|
## Issue #${issue.iid}: ${issue.title}
|
|
358
428
|
${issue.description || "(no description)"}
|
|
359
|
-
Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${accessBlock}${treeBlock}${previousBlock}${validationBlock}
|
|
429
|
+
Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${accessBlock}${treeBlock}${rulesBlock}${previousBlock}${validationBlock}
|
|
360
430
|
|
|
361
431
|
## Relevant files (current state as of the proposed changes)
|
|
362
432
|
${filesBlock}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyIssue,
|
|
5
|
+
buildIdentifyFilesPrompt,
|
|
6
|
+
buildProposeFixPrompt,
|
|
7
|
+
buildRefineOrAskPrompt,
|
|
8
|
+
} from "./prompts.js";
|
|
9
|
+
|
|
10
|
+
// ─── classifyIssue ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe("classifyIssue", () => {
|
|
13
|
+
it("detects feat prefix in title", () => {
|
|
14
|
+
expect(classifyIssue({ title: "feat(mixes): Live Mixes section" })).toBe("feature");
|
|
15
|
+
expect(classifyIssue({ title: "feat: add new page" })).toBe("feature");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("detects fix prefix in title", () => {
|
|
19
|
+
expect(classifyIssue({ title: "fix: broken auth flow" })).toBe("fix");
|
|
20
|
+
expect(classifyIssue({ title: "fix(auth): token refresh race" })).toBe("fix");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("detects chore prefix", () => {
|
|
24
|
+
expect(classifyIssue({ title: "chore: bump dependencies" })).toBe("chore");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("detects refactor prefix", () => {
|
|
28
|
+
expect(classifyIssue({ title: "refactor: extract auth service" })).toBe("refactor");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("falls back to labels when no title prefix", () => {
|
|
32
|
+
expect(classifyIssue({ title: "Add mixes page", labels: ["feature"] })).toBe("feature");
|
|
33
|
+
expect(classifyIssue({ title: "Add mixes page", labels: ["enhancement"] })).toBe("feature");
|
|
34
|
+
expect(classifyIssue({ title: "Auth broken", labels: ["bug"] })).toBe("fix");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles label objects with name field", () => {
|
|
38
|
+
expect(classifyIssue({ title: "Add page", labels: [{ name: "Feature" }] })).toBe("feature");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("falls back to description heuristics", () => {
|
|
42
|
+
expect(classifyIssue({
|
|
43
|
+
title: "Live Mixes section",
|
|
44
|
+
description: "## Approach\nReuse existing infrastructure...",
|
|
45
|
+
})).toBe("feature");
|
|
46
|
+
|
|
47
|
+
expect(classifyIssue({
|
|
48
|
+
title: "Multi-track mashups",
|
|
49
|
+
description: "## Frontend changes\n- Generalize StemsEngine...",
|
|
50
|
+
})).toBe("feature");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("defaults to fix when nothing matches", () => {
|
|
54
|
+
expect(classifyIssue({ title: "Something is wrong" })).toBe("fix");
|
|
55
|
+
expect(classifyIssue({ title: "Update thing", labels: [] })).toBe("fix");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("handles missing/null fields gracefully", () => {
|
|
59
|
+
expect(classifyIssue({})).toBe("fix");
|
|
60
|
+
expect(classifyIssue({ title: null, labels: null })).toBe("fix");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ─── buildIdentifyFilesPrompt ────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe("buildIdentifyFilesPrompt", () => {
|
|
67
|
+
const repoTrees = [
|
|
68
|
+
{ repo: "group/web-app", files: ["src/App.tsx", "src/types.ts"] },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
it("uses feature selection rules for feat issues", () => {
|
|
72
|
+
const issue = { iid: 25, title: "feat(mixes): Live Mixes section", labels: [] };
|
|
73
|
+
const prompt = buildIdentifyFilesPrompt(issue, repoTrees);
|
|
74
|
+
|
|
75
|
+
expect(prompt).toContain("feature implementation");
|
|
76
|
+
expect(prompt).toContain("PATTERNS");
|
|
77
|
+
expect(prompt).toContain("max 15");
|
|
78
|
+
expect(prompt).not.toContain("failing assertion");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("uses bug-fix selection rules for fix issues", () => {
|
|
82
|
+
const issue = { iid: 10, title: "fix: broken auth", labels: [] };
|
|
83
|
+
const prompt = buildIdentifyFilesPrompt(issue, repoTrees);
|
|
84
|
+
|
|
85
|
+
expect(prompt).toContain("bug fix");
|
|
86
|
+
expect(prompt).toContain("failing assertion");
|
|
87
|
+
expect(prompt).toContain("max 10");
|
|
88
|
+
expect(prompt).not.toContain("PATTERNS");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("says implementing for features, fixing for bugs", () => {
|
|
92
|
+
const feat = buildIdentifyFilesPrompt(
|
|
93
|
+
{ iid: 1, title: "feat: new page", labels: [] }, repoTrees,
|
|
94
|
+
);
|
|
95
|
+
const fix = buildIdentifyFilesPrompt(
|
|
96
|
+
{ iid: 2, title: "fix: crash", labels: [] }, repoTrees,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(feat).toContain("implementing");
|
|
100
|
+
expect(fix).toContain("fixing");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("includes multi-repo guidance when multiple repos present", () => {
|
|
104
|
+
const multi = [
|
|
105
|
+
{ repo: "group/web-app", files: ["src/App.tsx"] },
|
|
106
|
+
{ repo: "group/api", files: ["internal/handler.go"] },
|
|
107
|
+
];
|
|
108
|
+
const prompt = buildIdentifyFilesPrompt(
|
|
109
|
+
{ iid: 1, title: "fix: thing", labels: [] }, multi,
|
|
110
|
+
);
|
|
111
|
+
expect(prompt).toContain("Multi-repo guidance");
|
|
112
|
+
expect(prompt).toContain("group/web-app, group/api");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ─── buildProposeFixPrompt ───────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("buildProposeFixPrompt", () => {
|
|
119
|
+
const fileContents = [
|
|
120
|
+
{ repo: "group/web-app", path: "src/App.tsx", content: "export default function App() {}" },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
it("uses feat: prefix for feature issues", () => {
|
|
124
|
+
const issue = { iid: 25, title: "feat: add mixes", labels: [] };
|
|
125
|
+
const prompt = buildProposeFixPrompt(issue, fileContents);
|
|
126
|
+
|
|
127
|
+
expect(prompt).toContain('"feat:');
|
|
128
|
+
expect(prompt).toContain("Implement this feature");
|
|
129
|
+
expect(prompt).not.toContain("Root-cause discipline");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("uses fix: prefix for bug issues", () => {
|
|
133
|
+
const issue = { iid: 10, title: "fix: broken auth", labels: [] };
|
|
134
|
+
const prompt = buildProposeFixPrompt(issue, fileContents);
|
|
135
|
+
|
|
136
|
+
expect(prompt).toContain('"fix:');
|
|
137
|
+
expect(prompt).toContain("Propose a targeted fix");
|
|
138
|
+
expect(prompt).toContain("Root-cause discipline");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("includes feature discipline for feature issues", () => {
|
|
142
|
+
const issue = { iid: 25, title: "feat: mixes", labels: [] };
|
|
143
|
+
const prompt = buildProposeFixPrompt(issue, fileContents);
|
|
144
|
+
|
|
145
|
+
expect(prompt).toContain("Feature implementation rules");
|
|
146
|
+
expect(prompt).toContain("Follow the issue specification");
|
|
147
|
+
expect(prompt).toContain("Do NOT invent changes");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("injects promptRules when provided", () => {
|
|
151
|
+
const issue = { iid: 1, title: "fix: thing", labels: [] };
|
|
152
|
+
const prompt = buildProposeFixPrompt(issue, fileContents, {
|
|
153
|
+
promptRules: "Never restructure the NavBar",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(prompt).toContain("Custom rules");
|
|
157
|
+
expect(prompt).toContain("Never restructure the NavBar");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("omits promptRules block when empty", () => {
|
|
161
|
+
const issue = { iid: 1, title: "fix: thing", labels: [] };
|
|
162
|
+
const prompt = buildProposeFixPrompt(issue, fileContents, { promptRules: "" });
|
|
163
|
+
|
|
164
|
+
expect(prompt).not.toContain("Custom rules");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("includes CI context when provided", () => {
|
|
168
|
+
const issue = { iid: 1, title: "fix: thing", labels: [] };
|
|
169
|
+
const prompt = buildProposeFixPrompt(issue, fileContents, {
|
|
170
|
+
ciContext: "Error: Cannot find module './missing'",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(prompt).toContain("CI pipeline failure logs");
|
|
174
|
+
expect(prompt).toContain("Cannot find module");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ─── buildRefineOrAskPrompt ─────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("buildRefineOrAskPrompt", () => {
|
|
181
|
+
const issue = { iid: 1, title: "fix: thing", labels: [] };
|
|
182
|
+
const fileContents = [
|
|
183
|
+
{ repo: "r", path: "src/a.ts", content: "const x = 1;" },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
it("injects promptRules", () => {
|
|
187
|
+
const prompt = buildRefineOrAskPrompt(issue, fileContents, {
|
|
188
|
+
feedback: "fix the types",
|
|
189
|
+
promptRules: "Always use strict TypeScript",
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(prompt).toContain("Custom rules");
|
|
193
|
+
expect(prompt).toContain("Always use strict TypeScript");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("omits promptRules when empty", () => {
|
|
197
|
+
const prompt = buildRefineOrAskPrompt(issue, fileContents, {
|
|
198
|
+
feedback: "fix it",
|
|
199
|
+
promptRules: "",
|
|
200
|
+
});
|
|
201
|
+
expect(prompt).not.toContain("Custom rules");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("includes repo access block for tools mode", () => {
|
|
205
|
+
const prompt = buildRefineOrAskPrompt(issue, fileContents, {
|
|
206
|
+
feedback: "check types",
|
|
207
|
+
repoAccessMode: "tools",
|
|
208
|
+
});
|
|
209
|
+
expect(prompt).toContain("current working directory is a checkout");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("includes repo access block for tree mode", () => {
|
|
213
|
+
const prompt = buildRefineOrAskPrompt(issue, fileContents, {
|
|
214
|
+
feedback: "check types",
|
|
215
|
+
repoAccessMode: "tree",
|
|
216
|
+
});
|
|
217
|
+
expect(prompt).toContain("full file tree above");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -247,13 +247,14 @@ export class AzureDevOpsProvider extends ScmProvider {
|
|
|
247
247
|
|
|
248
248
|
// ─── Repository ───────────────────────────────────────────────────────────
|
|
249
249
|
|
|
250
|
-
async getFileTree(srcPath = "
|
|
250
|
+
async getFileTree(srcPath = "", ref = this._defaultBranch) {
|
|
251
|
+
const scopeParam = srcPath ? `&$scopePath=/${srcPath}` : "";
|
|
251
252
|
const data = await this._req(
|
|
252
|
-
`${this._gitBase()}/trees/${ref}?recursive=true
|
|
253
|
+
`${this._gitBase()}/trees/${ref}?recursive=true${scopeParam}`,
|
|
253
254
|
);
|
|
254
255
|
return (data?.treeEntries ?? [])
|
|
255
256
|
.filter((item) => item.gitObjectType === "blob")
|
|
256
|
-
.filter((item) => /\.(ts|tsx|js|jsx|json|css|yaml|yml|md)$/.test(item.relativePath))
|
|
257
|
+
.filter((item) => /\.(ts|tsx|js|jsx|json|css|yaml|yml|md|go|py|rs|java|rb|sh|toml)$/.test(item.relativePath))
|
|
257
258
|
.map((item) => item.relativePath);
|
|
258
259
|
}
|
|
259
260
|
|
|
@@ -186,13 +186,12 @@ export class GitHubProvider extends ScmProvider {
|
|
|
186
186
|
|
|
187
187
|
// ─── Repository ───────────────────────────────────────────────────────────
|
|
188
188
|
|
|
189
|
-
async getFileTree(srcPath = "
|
|
190
|
-
// Use recursive tree API — much faster than walking
|
|
189
|
+
async getFileTree(srcPath = "", ref = this._defaultBranch) {
|
|
191
190
|
const data = await this._req(`/git/trees/${ref}?recursive=1`);
|
|
192
191
|
return (data.tree ?? [])
|
|
193
192
|
.filter((item) => item.type === "blob")
|
|
194
|
-
.filter((item) => item.path.startsWith(srcPath + "/") || item.path === srcPath)
|
|
195
|
-
.filter((item) => /\.(ts|tsx|js|jsx|json|css|yaml|yml|md)$/.test(item.path))
|
|
193
|
+
.filter((item) => !srcPath || item.path.startsWith(srcPath + "/") || item.path === srcPath)
|
|
194
|
+
.filter((item) => /\.(ts|tsx|js|jsx|json|css|yaml|yml|md|go|py|rs|java|rb|sh|toml)$/.test(item.path))
|
|
196
195
|
.map((item) => item.path);
|
|
197
196
|
}
|
|
198
197
|
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GitLab REST API v4 implementation of ScmProvider.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Token resolved via configStore.getConnection("gitlab") — set in Settings UI
|
|
7
|
+
* or via GITLAB_TOKEN env var. Project resolved via configStore.getPrimaryProject()
|
|
8
|
+
* or the constructor argument.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { getConnection } from "../configStore.js";
|
|
11
|
+
import { getConnection, getPrimaryProject } from "../configStore.js";
|
|
12
12
|
import { ScmProvider } from "./base.js";
|
|
13
13
|
|
|
14
14
|
const CLOSING_ISSUE_RE = /\b(?:Closes|Fixes|Resolves)\s+#(\d+)/gi;
|
|
@@ -24,7 +24,7 @@ export class GitLabProvider extends ScmProvider {
|
|
|
24
24
|
/** @param {string} [project] */
|
|
25
25
|
constructor(project) {
|
|
26
26
|
super();
|
|
27
|
-
this._project = project ??
|
|
27
|
+
this._project = project ?? getPrimaryProject();
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
get name() { return "gitlab"; }
|
|
@@ -173,7 +173,7 @@ export class GitLabProvider extends ScmProvider {
|
|
|
173
173
|
|
|
174
174
|
// ─── Repository ───────────────────────────────────────────────────────────
|
|
175
175
|
|
|
176
|
-
async getFileTree(srcPath = "
|
|
176
|
+
async getFileTree(srcPath = "", ref = "main") {
|
|
177
177
|
const scan = async (pathParam) => {
|
|
178
178
|
const items = [];
|
|
179
179
|
let page = 1;
|
|
@@ -14,6 +14,7 @@ import fs from "node:fs/promises";
|
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
|
|
17
|
+
import { getConnection } from "../configStore.js";
|
|
17
18
|
import { runCmd } from "./cmd.js";
|
|
18
19
|
import { exists } from "./fs.js";
|
|
19
20
|
|
|
@@ -25,9 +26,9 @@ function sanitizeRepo(repo) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function gitlabCloneUrl(project) {
|
|
28
|
-
const token =
|
|
29
|
+
const { token } = getConnection("gitlab");
|
|
29
30
|
if (!token) {
|
|
30
|
-
throw new Error("
|
|
31
|
+
throw new Error("GitLab token is required to clone the repo for validation. Set it in Settings → Connections or via GITLAB_TOKEN env var.");
|
|
31
32
|
}
|
|
32
33
|
return `https://oauth2:${token}@gitlab.com/${project}.git`;
|
|
33
34
|
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { branchNameFor } from "./branchName.js";
|
|
29
|
+
import * as configStore from "./configStore.js";
|
|
29
30
|
import { attributeChanges } from "./validator/attribute.js";
|
|
30
31
|
import { ensureClone } from "./validator/clone.js";
|
|
31
32
|
import { overlayChanges } from "./validator/overlay.js";
|
|
@@ -86,8 +87,8 @@ function resolveCandidates(analysis) {
|
|
|
86
87
|
if (fromAnalysis.length) return fromAnalysis;
|
|
87
88
|
const fromChange = analysis.changes?.[0]?.repo;
|
|
88
89
|
if (fromChange) return [fromChange];
|
|
89
|
-
const
|
|
90
|
-
return
|
|
90
|
+
const primary = configStore.getPrimaryProject();
|
|
91
|
+
return primary ? [primary] : [];
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/**
|
package/server/forge/routes.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Express router for /api/forge/*.
|
|
5
5
|
*
|
|
6
6
|
* Required env vars:
|
|
7
|
-
* FORGE_TOKEN
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* FORGE_TOKEN — dashboard password
|
|
8
|
+
*
|
|
9
|
+
* Project + token resolved via configStore (Settings UI) with env-var fallbacks.
|
|
10
10
|
*
|
|
11
11
|
* Multi-repo support
|
|
12
12
|
* ──────────────────
|
|
@@ -80,9 +80,9 @@ function getAvailableRepos() {
|
|
|
80
80
|
function parseRepo(req) {
|
|
81
81
|
const repos = getAvailableRepos();
|
|
82
82
|
const requested = req.query.repo ?? req.body?.repo ?? null;
|
|
83
|
-
if (!requested) return repos[0]?.id ??
|
|
83
|
+
if (!requested) return repos[0]?.id ?? configStore.getPrimaryProject();
|
|
84
84
|
const match = repos.find((r) => r.id === requested);
|
|
85
|
-
return match?.id ?? repos[0]?.id ??
|
|
85
|
+
return match?.id ?? repos[0]?.id ?? configStore.getPrimaryProject();
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
@@ -177,10 +177,10 @@ router.get("/status", requireAuth, async (_req, res) => {
|
|
|
177
177
|
|
|
178
178
|
res.json({
|
|
179
179
|
ok: true,
|
|
180
|
-
project:
|
|
180
|
+
project: configStore.getPrimaryProject(),
|
|
181
181
|
repos,
|
|
182
182
|
supportsMultiRepo: repos.length > 1,
|
|
183
|
-
hasGitlabToken: Boolean(
|
|
183
|
+
hasGitlabToken: Boolean(configStore.getConnection("gitlab").token),
|
|
184
184
|
defaultModel: process.env.FORGE_MODEL ?? "claude-sonnet-4-6",
|
|
185
185
|
models,
|
|
186
186
|
agents,
|