@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 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 — users enter this to access Forge |
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
- ### GitLab (`SCM_PROVIDER=gitlab`, default)
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
- ### GitHub (`SCM_PROVIDER=github`)
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
- ### Azure DevOps (`SCM_PROVIDER=azuredevops`)
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.14.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": "^4.3.0",
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": "^5.4.0"
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 `fix:` prefix when the model didn't supply one.
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
- return `fix: ${analysis.issue.title.replace(CONV_PREFIX, "")}`;
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 ?? process.env.FORGE_GITLAB_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("src", "main");
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 >= MAX_FILES_TOTAL) break;
331
+ if (capped.length >= caps.total) break;
323
332
  const count = perRepo.get(entry.repo) ?? 0;
324
- if (count >= MAX_FILES_PER_REPO) continue;
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 { data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens } = await runClaude(
338
- buildProposeFixPrompt(issue, fileContents, options),
339
- schema,
340
- `You are Forge, an AI code assistant in a production React/TypeScript/Express codebase.
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 = process.env.GITLAB_TOKEN;
57
- if (!token) throw new Error("GITLAB_TOKEN is required to clone the repo locally.");
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 10 across all repos) relevant to fixing this issue.
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
- Selection rules:
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
- return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
261
-
262
- Schema: {"title": "fix: concise description of the actual change (max 72 chars)", "explanation": "...", "changes": [{"repo": "<repo id>", "file": "...", "action": "modify|create|delete", "proposed": "<full file content>", "summary": "..."}]}
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
- Root-cause discipline (read BEFORE writing any change):
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 = "src", ref = this._defaultBranch) {
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&$scopePath=/${srcPath}`,
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 = "src", ref = this._defaultBranch) {
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
- * Required env vars:
7
- * GITLAB_TOKEN — personal access token
8
- * FORGE_GITLAB_PROJECT — default project path, e.g. "ipul/web-app"
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 ?? process.env.FORGE_GITLAB_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 = "src", ref = "main") {
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 = process.env.GITLAB_TOKEN;
29
+ const { token } = getConnection("gitlab");
29
30
  if (!token) {
30
- throw new Error("GITLAB_TOKEN is required to clone the repo for validation.");
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 env = process.env.FORGE_GITLAB_PROJECT;
90
- return env ? [env] : [];
90
+ const primary = configStore.getPrimaryProject();
91
+ return primary ? [primary] : [];
91
92
  }
92
93
 
93
94
  /**
@@ -4,9 +4,9 @@
4
4
  * Express router for /api/forge/*.
5
5
  *
6
6
  * Required env vars:
7
- * FORGE_TOKEN — dashboard password
8
- * FORGE_GITLAB_PROJECT — GitLab project path, e.g. "ipul/web-app"
9
- * GITLAB_TOKEN — GitLab personal access token
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 ?? process.env.FORGE_GITLAB_PROJECT;
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 ?? process.env.FORGE_GITLAB_PROJECT;
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: process.env.FORGE_GITLAB_PROJECT ?? null,
180
+ project: configStore.getPrimaryProject(),
181
181
  repos,
182
182
  supportsMultiRepo: repos.length > 1,
183
- hasGitlabToken: Boolean(process.env.GITLAB_TOKEN),
183
+ hasGitlabToken: Boolean(configStore.getConnection("gitlab").token),
184
184
  defaultModel: process.env.FORGE_MODEL ?? "claude-sonnet-4-6",
185
185
  models,
186
186
  agents,