@ipul-labs/forge 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -96
- package/package.json +14 -22
- package/server/forge/core/ForgeAgent.js +42 -15
- package/server/forge/core/ForgeAgent.test.js +179 -0
- package/server/forge/core/claudeClient.js +27 -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 +124 -17
- 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 +10 -7
- package/dist-lib/favicon.svg +0 -8
- package/dist-lib/index.d.ts +0 -1
- package/dist-lib/style.css +0 -1
- package/dist-lib/ui.d.ts +0 -2
- package/dist-lib/ui.js +0 -5946
- package/dist-lib/views/Forge/ForgeDashboard.d.ts +0 -6
- package/dist-lib/views/Forge/activeJobs.d.ts +0 -29
- package/dist-lib/views/Forge/analysisCache.d.ts +0 -64
- package/dist-lib/views/Forge/api.d.ts +0 -4
- package/dist-lib/views/Forge/components/AnalysisProgress.d.ts +0 -13
- package/dist-lib/views/Forge/components/CITerminal.d.ts +0 -44
- package/dist-lib/views/Forge/components/DiffViewer.d.ts +0 -37
- package/dist-lib/views/Forge/components/ForgeConnect.d.ts +0 -6
- package/dist-lib/views/Forge/components/IssueList.d.ts +0 -28
- package/dist-lib/views/Forge/components/MRStatus.d.ts +0 -9
- package/dist-lib/views/Forge/components/NewTaskModal.d.ts +0 -1
- package/dist-lib/views/Forge/components/OpenMRList.d.ts +0 -11
- package/dist-lib/views/Forge/components/PipelineStatus.d.ts +0 -12
- package/dist-lib/views/Forge/components/PipelinesList.d.ts +0 -9
- package/dist-lib/views/Forge/components/RolloutStatus.d.ts +0 -5
- package/dist-lib/views/Forge/components/UsageWidget.d.ts +0 -8
- package/dist-lib/views/Forge/components/ValidationPanel.d.ts +0 -22
- package/dist-lib/views/Forge/context/ForgeProvider.d.ts +0 -23
- package/dist-lib/views/Forge/context/ForgeRouterContext.d.ts +0 -22
- package/dist-lib/views/Forge/hooks/useConfig.d.ts +0 -54
- package/dist-lib/views/Forge/hooks/useForgeDashboard.d.ts +0 -107
- package/dist-lib/views/Forge/hooks/useForgeQuota.d.ts +0 -22
- package/dist-lib/views/Forge/phases/AnalyzingView.d.ts +0 -1
- package/dist-lib/views/Forge/phases/IdleView.d.ts +0 -1
- package/dist-lib/views/Forge/phases/MonitoringIssueTab.d.ts +0 -1
- package/dist-lib/views/Forge/phases/MonitoringPipelineTab.d.ts +0 -1
- package/dist-lib/views/Forge/phases/MonitoringView.d.ts +0 -1
- package/dist-lib/views/Forge/phases/ReviewingView.d.ts +0 -1
- package/dist-lib/views/Forge/phases/SelectedIssueTab.d.ts +0 -11
- package/dist-lib/views/Forge/phases/SelectedMRTab.d.ts +0 -1
- package/dist-lib/views/Forge/phases/SelectedView.d.ts +0 -1
- package/dist-lib/views/Forge/phases/shared/CloseIssueConfirm.d.ts +0 -1
- package/dist-lib/views/Forge/phases/shared/DetailTabs.d.ts +0 -14
- package/dist-lib/views/Forge/phases/shared/EnvironmentsPanel.d.ts +0 -9
- package/dist-lib/views/Forge/phases/shared/ForgeActivity.d.ts +0 -1
- package/dist-lib/views/Forge/phases/shared/IssueHeaderBlock.d.ts +0 -13
- package/dist-lib/views/Forge/phases/shared/MergedMRList.d.ts +0 -4
- package/dist-lib/views/Forge/phases/shared/PreviousAnalyses.d.ts +0 -1
- package/dist-lib/views/Forge/phases/shared/ResolutionCheckPanel.d.ts +0 -6
- package/dist-lib/views/Forge/settings/ConnectionRow.d.ts +0 -16
- package/dist-lib/views/Forge/settings/ConnectionsSection.d.ts +0 -7
- package/dist-lib/views/Forge/settings/DefaultsSection.d.ts +0 -7
- package/dist-lib/views/Forge/settings/ReposSection.d.ts +0 -7
- package/dist-lib/views/Forge/settings/SettingsView.d.ts +0 -1
- package/dist-lib/views/Forge/shell/ConfigBar.d.ts +0 -1
- package/dist-lib/views/Forge/shell/ForgeRouter.d.ts +0 -9
- package/dist-lib/views/Forge/shell/ForgeShell.d.ts +0 -7
- package/dist-lib/views/Forge/shell/Sidebar.d.ts +0 -1
- package/dist-lib/views/Forge/shell/SidebarFooterMenu.d.ts +0 -1
- package/dist-lib/views/Forge/shell/SidebarNav.d.ts +0 -1
- package/dist-lib/views/Forge/shell/SidebarNavItem.d.ts +0 -10
- package/dist-lib/views/Forge/shell/TopBar.d.ts +0 -5
- package/dist-lib/views/Forge/types.d.ts +0 -235
- package/dist-lib/views/Forge/utils.d.ts +0 -11
|
@@ -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
|
+
});
|
|
@@ -14,6 +14,7 @@ import { spawn } from "child_process";
|
|
|
14
14
|
import {
|
|
15
15
|
SELECT_FILES_SCHEMA,
|
|
16
16
|
PROPOSE_FIX_SCHEMA,
|
|
17
|
+
CI_FIX_SCHEMA,
|
|
17
18
|
REFINE_OR_ASK_SCHEMA,
|
|
18
19
|
RESOLUTION_CHECK_SCHEMA,
|
|
19
20
|
buildIdentifyFilesPrompt,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
buildRefineOrAskPrompt,
|
|
22
23
|
buildRefineResumePrompt,
|
|
23
24
|
buildCheckResolutionPrompt,
|
|
25
|
+
classifyIssue,
|
|
24
26
|
} from "./prompts.js";
|
|
25
27
|
|
|
26
28
|
// FORGE_MODEL env var overrides the default. Set to a cheaper model to reduce cost.
|
|
@@ -332,10 +334,25 @@ Your final message MUST be a single raw JSON object matching the schema — no p
|
|
|
332
334
|
}
|
|
333
335
|
|
|
334
336
|
export async function proposeFix(issue, fileContents, options = {}, model, opts = {}) {
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
337
|
+
const schema = options.ciContext ? CI_FIX_SCHEMA : PROPOSE_FIX_SCHEMA;
|
|
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.
|
|
339
356
|
Rules:
|
|
340
357
|
- Changes must be minimal and surgical.
|
|
341
358
|
- Match the existing code style exactly (TypeScript strict, no barrel imports, Tailwind for UI).
|
|
@@ -348,7 +365,12 @@ Rules:
|
|
|
348
365
|
Root-cause discipline:
|
|
349
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.
|
|
350
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.
|
|
351
|
-
- 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,
|
|
352
374
|
model,
|
|
353
375
|
{ signal: opts.signal },
|
|
354
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
|
|
|
@@ -61,6 +61,43 @@ export const PROPOSE_FIX_SCHEMA = JSON.stringify({
|
|
|
61
61
|
required: ["title", "explanation", "changes"],
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
export const CI_FIX_SCHEMA = JSON.stringify({
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
unfixable: {
|
|
68
|
+
type: "boolean",
|
|
69
|
+
description: "True ONLY when the failure cannot be resolved by any source-code or package.json change (e.g. Docker registry outage, runner OOM, flaky network). Default false.",
|
|
70
|
+
},
|
|
71
|
+
unfixableReason: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "Required when unfixable=true. One sentence explaining why no code change can fix this.",
|
|
74
|
+
},
|
|
75
|
+
title: {
|
|
76
|
+
type: "string",
|
|
77
|
+
description: "Conventional-commit MR title describing the actual fix, e.g. 'fix: resolve token refresh race condition in AuthService'. Max 72 chars. No issue number.",
|
|
78
|
+
},
|
|
79
|
+
explanation: {
|
|
80
|
+
type: "string",
|
|
81
|
+
description: "2-4 sentences on root cause and approach. If changes span multiple repos, describe how the pieces fit together.",
|
|
82
|
+
},
|
|
83
|
+
changes: {
|
|
84
|
+
type: "array",
|
|
85
|
+
items: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
repo: { type: "string", description: "Repository id this change belongs to, exactly as given in the 'Available repositories' list." },
|
|
89
|
+
file: { type: "string", description: "Path from that repo's root." },
|
|
90
|
+
action: { type: "string", enum: ["create", "modify", "delete"] },
|
|
91
|
+
proposed: { type: "string", description: "For create/modify: the COMPLETE new file content. For delete: empty string." },
|
|
92
|
+
summary: { type: "string", description: "One sentence describing this file's change." },
|
|
93
|
+
},
|
|
94
|
+
required: ["repo", "file", "action", "proposed", "summary"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
required: ["title", "explanation", "changes"],
|
|
99
|
+
});
|
|
100
|
+
|
|
64
101
|
export const REFINE_OR_ASK_SCHEMA = JSON.stringify({
|
|
65
102
|
type: "object",
|
|
66
103
|
properties: {
|
|
@@ -111,6 +148,30 @@ export const RESOLUTION_CHECK_SCHEMA = JSON.stringify({
|
|
|
111
148
|
required: ["resolved", "reasoning"],
|
|
112
149
|
});
|
|
113
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
|
+
|
|
114
175
|
/**
|
|
115
176
|
* Builds a human-readable block listing each repo's file tree.
|
|
116
177
|
* Accepts either:
|
|
@@ -161,17 +222,32 @@ export function buildIdentifyFilesPrompt(issue, repoTrees) {
|
|
|
161
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.`
|
|
162
223
|
: "";
|
|
163
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
|
+
|
|
164
243
|
return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
|
|
165
244
|
|
|
166
245
|
Schema: {"files": [{"repo": "<repo id>", "path": "src/foo/bar.ts"}], "reasoning": "one or two sentences"}
|
|
167
246
|
|
|
168
|
-
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.
|
|
169
248
|
Always use an exact repo id from the "Available repositories" list below — do not invent or abbreviate.
|
|
170
249
|
|
|
171
|
-
|
|
172
|
-
- 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.
|
|
173
|
-
- When a bug points at an event handler or React component, include the component that renders the handler AND the handler's implementation.
|
|
174
|
-
- 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}
|
|
175
251
|
|
|
176
252
|
## Issue #${issue.iid}: ${issue.title}
|
|
177
253
|
${issue.description || "(no description)"}
|
|
@@ -185,9 +261,11 @@ ${renderRepoTrees(repoTrees)}`;
|
|
|
185
261
|
}
|
|
186
262
|
|
|
187
263
|
export function buildProposeFixPrompt(issue, fileContents, options = {}) {
|
|
188
|
-
const { feedback, previousChanges, ciContext, userPrompt, repos } = options;
|
|
264
|
+
const { feedback, previousChanges, ciContext, userPrompt, repos, promptRules } = options;
|
|
189
265
|
|
|
190
266
|
const filesBlock = renderFileContents(fileContents);
|
|
267
|
+
const kind = classifyIssue(issue);
|
|
268
|
+
const isFeature = kind === "feature" || kind === "refactor";
|
|
191
269
|
|
|
192
270
|
const previousBlock = previousChanges?.length
|
|
193
271
|
? `\n\n## Previous proposed changes\n${renderPreviousChanges(previousChanges)}`
|
|
@@ -198,7 +276,14 @@ export function buildProposeFixPrompt(issue, fileContents, options = {}) {
|
|
|
198
276
|
: "";
|
|
199
277
|
|
|
200
278
|
const ciBlock = ciContext?.trim()
|
|
201
|
-
? `\n\n## CI pipeline failure logs
|
|
279
|
+
? `\n\n## CI pipeline failure logs
|
|
280
|
+
The pipeline failed. Read the logs carefully and classify the failure:
|
|
281
|
+
|
|
282
|
+
1. **Code/test failure** — broken imports, type errors, failing assertions → propose the source-code fix.
|
|
283
|
+
2. **Dependency audit failure** (npm audit, yarn audit) — a package has a known vulnerability → update the version constraint in package.json. Do NOT touch package-lock.json; the lock file will be regenerated automatically.
|
|
284
|
+
3. **Unfixable infrastructure failure** — Docker registry outage, runner OOM, network timeout, flaky CI runner → set "unfixable": true and explain why in "unfixableReason". Return an empty "changes" array.
|
|
285
|
+
|
|
286
|
+
${ciContext.trim()}`
|
|
202
287
|
: "";
|
|
203
288
|
|
|
204
289
|
const userBlock = userPrompt?.trim()
|
|
@@ -213,21 +298,39 @@ export function buildProposeFixPrompt(issue, fileContents, options = {}) {
|
|
|
213
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.`
|
|
214
299
|
: "";
|
|
215
300
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
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
|
+
: "";
|
|
221
304
|
|
|
222
|
-
|
|
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 discipline = isFeature
|
|
311
|
+
? `Feature implementation rules (read BEFORE writing any change):
|
|
312
|
+
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.
|
|
313
|
+
2. When the issue references existing components or patterns to reuse, base your implementation on the existing patterns in the "Relevant files" section.
|
|
314
|
+
3. For new files, follow the naming conventions, import style, and structure of the existing codebase.
|
|
315
|
+
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".
|
|
316
|
+
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.`
|
|
317
|
+
: `Root-cause discipline (read BEFORE writing any change):
|
|
223
318
|
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.
|
|
224
319
|
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.
|
|
225
320
|
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.
|
|
226
|
-
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
|
|
321
|
+
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.`;
|
|
322
|
+
|
|
323
|
+
return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
|
|
324
|
+
|
|
325
|
+
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": "..."}]}
|
|
326
|
+
|
|
327
|
+
${isFeature ? "Implement this feature." : "Propose a targeted fix for this issue."}
|
|
328
|
+
|
|
329
|
+
${discipline}
|
|
227
330
|
|
|
228
331
|
## Issue #${issue.iid}: ${issue.title}
|
|
229
332
|
${issue.description || "(no description)"}
|
|
230
|
-
Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${multiNote}${previousBlock}${feedbackBlock}${ciBlock}${userBlock}
|
|
333
|
+
Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${multiNote}${rulesBlock}${previousBlock}${feedbackBlock}${ciBlock}${userBlock}
|
|
231
334
|
|
|
232
335
|
## Relevant files
|
|
233
336
|
${filesBlock}`;
|
|
@@ -264,7 +367,7 @@ function renderValidationState(state) {
|
|
|
264
367
|
* responds with EITHER a grounded answer OR a fresh changes[] payload.
|
|
265
368
|
*/
|
|
266
369
|
export function buildRefineOrAskPrompt(issue, fileContents, options = {}) {
|
|
267
|
-
const { feedback, previousChanges, validationState, repos, repoTrees, repoAccessMode } = options;
|
|
370
|
+
const { feedback, previousChanges, validationState, repos, repoTrees, repoAccessMode, promptRules } = options;
|
|
268
371
|
|
|
269
372
|
const filesBlock = renderFileContents(fileContents);
|
|
270
373
|
const previousBlock = previousChanges?.length
|
|
@@ -283,6 +386,10 @@ export function buildRefineOrAskPrompt(issue, fileContents, options = {}) {
|
|
|
283
386
|
? `\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.`
|
|
284
387
|
: `\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.`;
|
|
285
388
|
|
|
389
|
+
const rulesBlock = promptRules?.trim()
|
|
390
|
+
? `\n\n## Custom rules (from project settings — always follow these)\n${promptRules.trim()}`
|
|
391
|
+
: "";
|
|
392
|
+
|
|
286
393
|
return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
|
|
287
394
|
|
|
288
395
|
Schema: {
|
|
@@ -312,7 +419,7 @@ If the message is ambiguous, prefer "answer" and ask a clarifying question.
|
|
|
312
419
|
|
|
313
420
|
## Issue #${issue.iid}: ${issue.title}
|
|
314
421
|
${issue.description || "(no description)"}
|
|
315
|
-
Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${accessBlock}${treeBlock}${previousBlock}${validationBlock}
|
|
422
|
+
Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${accessBlock}${treeBlock}${rulesBlock}${previousBlock}${validationBlock}
|
|
316
423
|
|
|
317
424
|
## Relevant files (current state as of the proposed changes)
|
|
318
425
|
${filesBlock}
|