@ipul-labs/forge 1.13.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.
Files changed (113) hide show
  1. package/README.md +273 -0
  2. package/bin/README.md +210 -0
  3. package/bin/cli/analyze.js +516 -0
  4. package/bin/cli/apply.js +264 -0
  5. package/bin/cli/cifix.js +300 -0
  6. package/bin/cli/close.js +125 -0
  7. package/bin/cli/doctor.js +161 -0
  8. package/bin/cli/init.js +306 -0
  9. package/bin/cli/ink.js +57 -0
  10. package/bin/cli/issue.js +116 -0
  11. package/bin/cli/issues.js +155 -0
  12. package/bin/cli/merge.js +175 -0
  13. package/bin/cli/refine.js +253 -0
  14. package/bin/cli/repos.js +91 -0
  15. package/bin/cli/resolution.js +126 -0
  16. package/bin/cli/session.js +230 -0
  17. package/bin/cli/sessionStore.js +214 -0
  18. package/bin/cli/ship.js +762 -0
  19. package/bin/cli/utils.js +145 -0
  20. package/bin/cli/validate.js +280 -0
  21. package/bin/cli/watch.js +274 -0
  22. package/bin/forge-agent.js +174 -0
  23. package/dist/assets/index-CNTCMbW9.js +80 -0
  24. package/dist/assets/index-InpYS-zg.css +1 -0
  25. package/dist/favicon.svg +8 -0
  26. package/dist/index.html +19 -0
  27. package/dist-lib/favicon.svg +8 -0
  28. package/dist-lib/index.d.ts +1 -0
  29. package/dist-lib/style.css +1 -0
  30. package/dist-lib/ui.d.ts +2 -0
  31. package/dist-lib/ui.js +7390 -0
  32. package/dist-lib/views/Forge/ForgeDashboard.d.ts +5 -0
  33. package/dist-lib/views/Forge/activeJobs.d.ts +29 -0
  34. package/dist-lib/views/Forge/analysisCache.d.ts +64 -0
  35. package/dist-lib/views/Forge/api.d.ts +4 -0
  36. package/dist-lib/views/Forge/components/AnalysisProgress.d.ts +13 -0
  37. package/dist-lib/views/Forge/components/CITerminal.d.ts +44 -0
  38. package/dist-lib/views/Forge/components/DiffViewer.d.ts +37 -0
  39. package/dist-lib/views/Forge/components/ForgeConnect.d.ts +6 -0
  40. package/dist-lib/views/Forge/components/IssueList.d.ts +28 -0
  41. package/dist-lib/views/Forge/components/MRStatus.d.ts +9 -0
  42. package/dist-lib/views/Forge/components/NewTaskModal.d.ts +1 -0
  43. package/dist-lib/views/Forge/components/OpenMRList.d.ts +11 -0
  44. package/dist-lib/views/Forge/components/PipelineStatus.d.ts +12 -0
  45. package/dist-lib/views/Forge/components/PipelinesList.d.ts +9 -0
  46. package/dist-lib/views/Forge/components/RolloutStatus.d.ts +5 -0
  47. package/dist-lib/views/Forge/components/UsageWidget.d.ts +8 -0
  48. package/dist-lib/views/Forge/components/ValidationPanel.d.ts +22 -0
  49. package/dist-lib/views/Forge/context/ForgeProvider.d.ts +23 -0
  50. package/dist-lib/views/Forge/hooks/useConfig.d.ts +54 -0
  51. package/dist-lib/views/Forge/hooks/useForgeDashboard.d.ts +107 -0
  52. package/dist-lib/views/Forge/hooks/useForgeQuota.d.ts +22 -0
  53. package/dist-lib/views/Forge/phases/AnalyzingView.d.ts +1 -0
  54. package/dist-lib/views/Forge/phases/IdleView.d.ts +1 -0
  55. package/dist-lib/views/Forge/phases/MonitoringIssueTab.d.ts +1 -0
  56. package/dist-lib/views/Forge/phases/MonitoringPipelineTab.d.ts +1 -0
  57. package/dist-lib/views/Forge/phases/MonitoringView.d.ts +1 -0
  58. package/dist-lib/views/Forge/phases/ReviewingView.d.ts +1 -0
  59. package/dist-lib/views/Forge/phases/SelectedIssueTab.d.ts +11 -0
  60. package/dist-lib/views/Forge/phases/SelectedMRTab.d.ts +1 -0
  61. package/dist-lib/views/Forge/phases/SelectedView.d.ts +1 -0
  62. package/dist-lib/views/Forge/phases/shared/CloseIssueConfirm.d.ts +1 -0
  63. package/dist-lib/views/Forge/phases/shared/DetailTabs.d.ts +14 -0
  64. package/dist-lib/views/Forge/phases/shared/EnvironmentsPanel.d.ts +9 -0
  65. package/dist-lib/views/Forge/phases/shared/ForgeActivity.d.ts +1 -0
  66. package/dist-lib/views/Forge/phases/shared/IssueHeaderBlock.d.ts +13 -0
  67. package/dist-lib/views/Forge/phases/shared/MergedMRList.d.ts +4 -0
  68. package/dist-lib/views/Forge/phases/shared/PreviousAnalyses.d.ts +1 -0
  69. package/dist-lib/views/Forge/phases/shared/ResolutionCheckPanel.d.ts +6 -0
  70. package/dist-lib/views/Forge/settings/ConnectionRow.d.ts +16 -0
  71. package/dist-lib/views/Forge/settings/ConnectionsSection.d.ts +7 -0
  72. package/dist-lib/views/Forge/settings/DefaultsSection.d.ts +7 -0
  73. package/dist-lib/views/Forge/settings/ReposSection.d.ts +7 -0
  74. package/dist-lib/views/Forge/settings/SettingsView.d.ts +1 -0
  75. package/dist-lib/views/Forge/shell/ConfigBar.d.ts +1 -0
  76. package/dist-lib/views/Forge/shell/ForgeRouter.d.ts +9 -0
  77. package/dist-lib/views/Forge/shell/ForgeShell.d.ts +7 -0
  78. package/dist-lib/views/Forge/shell/Sidebar.d.ts +1 -0
  79. package/dist-lib/views/Forge/shell/SidebarFooterMenu.d.ts +1 -0
  80. package/dist-lib/views/Forge/shell/SidebarNav.d.ts +1 -0
  81. package/dist-lib/views/Forge/shell/SidebarNavItem.d.ts +10 -0
  82. package/dist-lib/views/Forge/shell/TopBar.d.ts +5 -0
  83. package/dist-lib/views/Forge/types.d.ts +235 -0
  84. package/dist-lib/views/Forge/utils.d.ts +11 -0
  85. package/package.json +88 -0
  86. package/server/forge/core/ForgeAgent.js +1144 -0
  87. package/server/forge/core/agentClient.js +36 -0
  88. package/server/forge/core/branchName.js +41 -0
  89. package/server/forge/core/claudeClient.js +356 -0
  90. package/server/forge/core/configStore.js +214 -0
  91. package/server/forge/core/gitlabClient.js +340 -0
  92. package/server/forge/core/jobs.js +180 -0
  93. package/server/forge/core/k8s.js +54 -0
  94. package/server/forge/core/localRepo.js +136 -0
  95. package/server/forge/core/openclawClient.js +125 -0
  96. package/server/forge/core/prompts.js +369 -0
  97. package/server/forge/core/providers/azuredevops.js +408 -0
  98. package/server/forge/core/providers/base.js +208 -0
  99. package/server/forge/core/providers/github.js +349 -0
  100. package/server/forge/core/providers/gitlab.js +529 -0
  101. package/server/forge/core/providers/index.js +47 -0
  102. package/server/forge/core/quotaClient.js +101 -0
  103. package/server/forge/core/validator/attribute.js +84 -0
  104. package/server/forge/core/validator/clone.js +91 -0
  105. package/server/forge/core/validator/cmd.js +98 -0
  106. package/server/forge/core/validator/fs.js +9 -0
  107. package/server/forge/core/validator/overlay.js +52 -0
  108. package/server/forge/core/validator/projectType.js +87 -0
  109. package/server/forge/core/validator/runners/go.js +107 -0
  110. package/server/forge/core/validator/runners/node.js +84 -0
  111. package/server/forge/core/validator/tdd.js +105 -0
  112. package/server/forge/core/validator.js +166 -0
  113. package/server/forge/routes.js +1012 -0
@@ -0,0 +1,529 @@
1
+ /**
2
+ * server/forge/core/providers/gitlab.js
3
+ *
4
+ * GitLab REST API v4 implementation of ScmProvider.
5
+ *
6
+ * Required env vars:
7
+ * GITLAB_TOKEN — personal access token
8
+ * FORGE_GITLAB_PROJECT — default project path, e.g. "ipul/web-app"
9
+ */
10
+
11
+ import { getConnection } from "../configStore.js";
12
+ import { ScmProvider } from "./base.js";
13
+
14
+ const CLOSING_ISSUE_RE = /\b(?:Closes|Fixes|Resolves)\s+#(\d+)/gi;
15
+
16
+ function parseClosingIssueRefs(text) {
17
+ if (!text) return [];
18
+ const seen = new Set();
19
+ for (const m of text.matchAll(CLOSING_ISSUE_RE)) seen.add(Number(m[1]));
20
+ return [...seen];
21
+ }
22
+
23
+ export class GitLabProvider extends ScmProvider {
24
+ /** @param {string} [project] */
25
+ constructor(project) {
26
+ super();
27
+ this._project = project ?? process.env.FORGE_GITLAB_PROJECT;
28
+ }
29
+
30
+ get name() { return "gitlab"; }
31
+ get supportsRebase() { return true; }
32
+
33
+ get _base() {
34
+ return getConnection("gitlab").baseUrl;
35
+ }
36
+
37
+ // ─── Internal helpers ──────────────────────────────────────────────────────
38
+
39
+ _headers() {
40
+ return {
41
+ "PRIVATE-TOKEN": getConnection("gitlab").token,
42
+ "Content-Type": "application/json",
43
+ };
44
+ }
45
+
46
+ _enc() {
47
+ return encodeURIComponent(this._project);
48
+ }
49
+
50
+ async _req(path, options = {}) {
51
+ const res = await fetch(`${this._base}${path}`, {
52
+ ...options,
53
+ headers: { ...this._headers(), ...options.headers },
54
+ });
55
+ if (!res.ok) {
56
+ const text = await res.text().catch(() => "");
57
+ throw new Error(`GitLab ${res.status} on ${path}: ${text}`);
58
+ }
59
+ return res.json();
60
+ }
61
+
62
+ // ─── Normalizers ──────────────────────────────────────────────────────────
63
+
64
+ /** @param {object} raw @returns {import('./base.js').NormalIssue} */
65
+ _issue(raw) {
66
+ return {
67
+ iid: raw.iid,
68
+ title: raw.title,
69
+ description: raw.description ?? null,
70
+ labels: raw.labels ?? [],
71
+ author: { name: raw.author?.name ?? "" },
72
+ created_at: raw.created_at,
73
+ web_url: raw.web_url,
74
+ };
75
+ }
76
+
77
+ /** @param {object} raw @returns {import('./base.js').NormalMR} */
78
+ _mr(raw) {
79
+ return {
80
+ iid: raw.iid,
81
+ title: raw.title,
82
+ web_url: raw.web_url,
83
+ source_branch: raw.source_branch,
84
+ target_branch: raw.target_branch,
85
+ state: raw.state,
86
+ has_conflicts: raw.has_conflicts ?? false,
87
+ rebase_in_progress: raw.rebase_in_progress ?? false,
88
+ merge_status: raw.merge_status ?? "",
89
+ detailed_merge_status: raw.detailed_merge_status ?? null,
90
+ merge_when_pipeline_succeeds: raw.merge_when_pipeline_succeeds ?? false,
91
+ draft: raw.draft ?? raw.work_in_progress ?? false,
92
+ head_pipeline: raw.head_pipeline
93
+ ? { status: raw.head_pipeline.status, web_url: raw.head_pipeline.web_url }
94
+ : null,
95
+ author: { name: raw.author?.name ?? "" },
96
+ created_at: raw.created_at,
97
+ merged_at: raw.merged_at ?? null,
98
+ merge_commit_sha: raw.merge_commit_sha ?? null,
99
+ };
100
+ }
101
+
102
+ /** @param {object} raw @returns {import('./base.js').NormalPipeline} */
103
+ _pipeline(raw) {
104
+ return {
105
+ id: raw.id,
106
+ status: raw.status,
107
+ web_url: raw.web_url,
108
+ created_at: raw.created_at,
109
+ };
110
+ }
111
+
112
+ /** @param {object} raw @returns {import('./base.js').NormalJob} */
113
+ _job(raw) {
114
+ return {
115
+ id: raw.id,
116
+ name: raw.name,
117
+ stage: raw.stage,
118
+ status: raw.status,
119
+ web_url: raw.web_url,
120
+ duration: raw.duration ?? null,
121
+ };
122
+ }
123
+
124
+ /** @param {object} raw @returns {import('./base.js').NormalDiff} */
125
+ _diff(raw) {
126
+ return {
127
+ old_path: raw.old_path,
128
+ new_path: raw.new_path,
129
+ diff: raw.diff ?? "",
130
+ new_file: raw.new_file ?? false,
131
+ renamed_file: raw.renamed_file ?? false,
132
+ deleted_file: raw.deleted_file ?? false,
133
+ };
134
+ }
135
+
136
+ // ─── Issues ───────────────────────────────────────────────────────────────
137
+
138
+ async listOpenIssues() {
139
+ const raw = await this._req(
140
+ `/projects/${this._enc()}/issues?state=opened&order_by=updated_at&per_page=25`,
141
+ );
142
+ return raw.map((r) => this._issue(r));
143
+ }
144
+
145
+ async getIssue(iid) {
146
+ const raw = await this._req(`/projects/${this._enc()}/issues/${iid}`);
147
+ return this._issue(raw);
148
+ }
149
+
150
+ async createIssue(title, description = "") {
151
+ const raw = await this._req(`/projects/${this._enc()}/issues`, {
152
+ method: "POST",
153
+ body: JSON.stringify({ title, description }),
154
+ });
155
+ return this._issue(raw);
156
+ }
157
+
158
+ async updateIssue(iid, { title, description }) {
159
+ const raw = await this._req(`/projects/${this._enc()}/issues/${iid}`, {
160
+ method: "PUT",
161
+ body: JSON.stringify({ title, description }),
162
+ });
163
+ return this._issue(raw);
164
+ }
165
+
166
+ async closeIssue(iid) {
167
+ const raw = await this._req(`/projects/${this._enc()}/issues/${iid}`, {
168
+ method: "PUT",
169
+ body: JSON.stringify({ state_event: "close" }),
170
+ });
171
+ return this._issue(raw);
172
+ }
173
+
174
+ // ─── Repository ───────────────────────────────────────────────────────────
175
+
176
+ async getFileTree(srcPath = "src", ref = "main") {
177
+ const scan = async (pathParam) => {
178
+ const items = [];
179
+ let page = 1;
180
+ while (true) {
181
+ const batch = await this._req(
182
+ `/projects/${this._enc()}/repository/tree` +
183
+ `?${pathParam ? `path=${pathParam}&` : ""}recursive=true&ref=${ref}&per_page=100&page=${page}`,
184
+ );
185
+ items.push(...batch);
186
+ if (batch.length < 100) break;
187
+ page++;
188
+ }
189
+ return items;
190
+ };
191
+
192
+ let allItems;
193
+ try {
194
+ allItems = await scan(srcPath);
195
+ } catch (err) {
196
+ // The srcPath defaults to "src" but not every repo uses that layout
197
+ // (Go repos use go/, Python uses app/, etc.). Fall back to full tree.
198
+ if (/404|invalid revision or path/i.test(err?.message ?? "")) {
199
+ allItems = await scan("");
200
+ } else {
201
+ throw err;
202
+ }
203
+ }
204
+
205
+ return allItems
206
+ .filter((item) => item.type === "blob")
207
+ .filter((item) => /\.(ts|tsx|js|jsx|json|css|yaml|yml|md|go|py|rs|java|rb|sh|toml)$/.test(item.path))
208
+ .map((item) => item.path);
209
+ }
210
+
211
+ async getFileContent(filePath, ref = "main") {
212
+ const encoded = encodeURIComponent(filePath);
213
+ const data = await this._req(
214
+ `/projects/${this._enc()}/repository/files/${encoded}?ref=${ref}`,
215
+ );
216
+ return Buffer.from(data.content, "base64").toString("utf-8");
217
+ }
218
+
219
+ // ─── Mutations ────────────────────────────────────────────────────────────
220
+
221
+ async createBranch(branchName, ref = "main") {
222
+ return this._req(`/projects/${this._enc()}/repository/branches`, {
223
+ method: "POST",
224
+ body: JSON.stringify({ branch: branchName, ref }),
225
+ });
226
+ }
227
+
228
+ async deleteBranch(branchName) {
229
+ const res = await fetch(
230
+ `${this._base}/projects/${this._enc()}/repository/branches/${encodeURIComponent(branchName)}`,
231
+ { method: "DELETE", headers: this._headers() },
232
+ );
233
+ // 404 is fine — branch was already gone.
234
+ if (!res.ok && res.status !== 404) {
235
+ const text = await res.text().catch(() => "");
236
+ throw new Error(`GitLab ${res.status} deleting ${branchName}: ${text}`);
237
+ }
238
+ }
239
+
240
+ async commitFiles(branch, message, actions) {
241
+ // GitLab uses "update" where the base interface uses "modify"
242
+ const mapped = actions.map((a) => ({
243
+ ...a,
244
+ action: a.action === "modify" ? "update" : a.action,
245
+ }));
246
+ const payload = { branch, commit_message: message, actions: mapped };
247
+ try {
248
+ return await this._req(`/projects/${this._enc()}/repository/commits`, {
249
+ method: "POST",
250
+ body: JSON.stringify(payload),
251
+ });
252
+ } catch (err) {
253
+ // When committing to an existing branch, a file that was previously
254
+ // created now exists — retry with create→update for all such actions.
255
+ if (err.message.includes("already exists") && mapped.some((a) => a.action === "create")) {
256
+ const fixed = mapped.map((a) => ({
257
+ ...a,
258
+ action: a.action === "create" ? "update" : a.action,
259
+ }));
260
+ return this._req(`/projects/${this._enc()}/repository/commits`, {
261
+ method: "POST",
262
+ body: JSON.stringify({ ...payload, actions: fixed }),
263
+ });
264
+ }
265
+ throw err;
266
+ }
267
+ }
268
+
269
+ async createMR({ sourceBranch, targetBranch = "main", title, description = "", labels = [] }) {
270
+ const body = {
271
+ source_branch: sourceBranch,
272
+ target_branch: targetBranch,
273
+ title,
274
+ description,
275
+ remove_source_branch: true,
276
+ };
277
+ if (labels?.length) body.labels = labels.join(",");
278
+ const raw = await this._req(`/projects/${this._enc()}/merge_requests`, {
279
+ method: "POST",
280
+ body: JSON.stringify(body),
281
+ });
282
+ return this._mr(raw);
283
+ }
284
+
285
+ // ─── MR / PR queries ──────────────────────────────────────────────────────
286
+
287
+ async listAllForgeMRs(state = "open") {
288
+ const gitlabState = state === "open" ? "opened" : state;
289
+ const all = await this._req(
290
+ `/projects/${this._enc()}/merge_requests?state=${gitlabState}&per_page=50`,
291
+ );
292
+ const forge = all.filter((mr) => mr.source_branch?.startsWith("forge/"));
293
+ // Only open MRs need the head_pipeline detail fetch — merged/closed rows
294
+ // don't render a live pipeline badge in the UI.
295
+ if (state !== "open") return forge.map((mr) => this._mr(mr));
296
+ const detailed = await Promise.all(
297
+ forge.map((mr) => this._req(`/projects/${this._enc()}/merge_requests/${mr.iid}`)),
298
+ );
299
+ return detailed.map((mr) => this._mr(mr));
300
+ }
301
+
302
+ async listForgeMRsForIssue(issueIid) {
303
+ const all = await this._req(
304
+ `/projects/${this._enc()}/merge_requests?state=opened&per_page=50`,
305
+ );
306
+ return all
307
+ .filter((mr) => mr.source_branch?.startsWith(`forge/issue-${issueIid}-`))
308
+ .map((mr) => this._mr(mr));
309
+ }
310
+
311
+ async listMergedForgeMRsForIssue(issueIid) {
312
+ const all = await this._req(
313
+ `/projects/${this._enc()}/merge_requests?state=merged&per_page=50`,
314
+ );
315
+ return all
316
+ .filter((mr) => mr.source_branch?.startsWith(`forge/issue-${issueIid}-`))
317
+ .map((mr) => this._mr(mr));
318
+ }
319
+
320
+ async getMR(mrIid) {
321
+ const raw = await this._req(
322
+ `/projects/${this._enc()}/merge_requests/${mrIid}`,
323
+ );
324
+ return this._mr(raw);
325
+ }
326
+
327
+ async getMRDiffs(mrIid) {
328
+ const allDiffs = [];
329
+ let page = 1;
330
+ while (true) {
331
+ const items = await this._req(
332
+ `/projects/${this._enc()}/merge_requests/${mrIid}/diffs?per_page=20&page=${page}`,
333
+ );
334
+ allDiffs.push(...items);
335
+ if (items.length < 20) break;
336
+ page++;
337
+ }
338
+ return allDiffs.map((d) => this._diff(d));
339
+ }
340
+
341
+ async rebaseMR(mrIid) {
342
+ await this._req(
343
+ `/projects/${this._enc()}/merge_requests/${mrIid}/rebase`,
344
+ { method: "PUT" },
345
+ );
346
+ }
347
+
348
+ async setAutoMergeMR(mrIid) {
349
+ await this._req(
350
+ `/projects/${this._enc()}/merge_requests/${mrIid}/merge`,
351
+ { method: "PUT", body: JSON.stringify({ merge_when_pipeline_succeeds: true }) },
352
+ );
353
+ }
354
+
355
+ async cancelAutoMergeMR(mrIid) {
356
+ await this._req(
357
+ `/projects/${this._enc()}/merge_requests/${mrIid}/cancel_merge_when_pipeline_succeeds`,
358
+ { method: "POST" },
359
+ );
360
+ }
361
+
362
+ async mergeMR(mrIid) {
363
+ await this._req(
364
+ `/projects/${this._enc()}/merge_requests/${mrIid}/merge`,
365
+ { method: "PUT", body: JSON.stringify({ should_remove_source_branch: true }) },
366
+ );
367
+ }
368
+
369
+ async closeMR(mrIid) {
370
+ await this._req(
371
+ `/projects/${this._enc()}/merge_requests/${mrIid}`,
372
+ { method: "PUT", body: JSON.stringify({ state_event: "close" }) },
373
+ );
374
+ }
375
+
376
+ async reopenMR(mrIid) {
377
+ await this._req(
378
+ `/projects/${this._enc()}/merge_requests/${mrIid}`,
379
+ { method: "PUT", body: JSON.stringify({ state_event: "reopen" }) },
380
+ );
381
+ }
382
+
383
+ async setMRDraft(mrIid, draft) {
384
+ // GitLab marks an MR as draft by prefixing the title with "Draft: ".
385
+ // Fetch, adjust, and write back — the update endpoint has no boolean flag.
386
+ const current = await this._req(
387
+ `/projects/${this._enc()}/merge_requests/${mrIid}`,
388
+ );
389
+ const title = String(current.title ?? "");
390
+ const stripped = title.replace(/^(Draft:|WIP:)\s*/i, "");
391
+ const nextTitle = draft ? `Draft: ${stripped}` : stripped;
392
+ if (nextTitle === title) return;
393
+ await this._req(
394
+ `/projects/${this._enc()}/merge_requests/${mrIid}`,
395
+ { method: "PUT", body: JSON.stringify({ title: nextTitle }) },
396
+ );
397
+ }
398
+
399
+ // ─── CI / Pipelines ───────────────────────────────────────────────────────
400
+
401
+ async getMRPipelines(mrIid) {
402
+ const raw = await this._req(
403
+ `/projects/${this._enc()}/merge_requests/${mrIid}/pipelines`,
404
+ );
405
+ return raw.map((p) => this._pipeline(p));
406
+ }
407
+
408
+ async getPipeline(pipelineId) {
409
+ const raw = await this._req(
410
+ `/projects/${this._enc()}/pipelines/${pipelineId}`,
411
+ );
412
+ return this._pipeline(raw);
413
+ }
414
+
415
+ async getPipelineJobs(pipelineId) {
416
+ const raw = await this._req(
417
+ `/projects/${this._enc()}/pipelines/${pipelineId}/jobs`,
418
+ );
419
+ return raw.map((j) => this._job(j));
420
+ }
421
+
422
+ async getJobTrace(jobId) {
423
+ const url = `${this._base}/projects/${this._enc()}/jobs/${jobId}/trace`;
424
+ const res = await fetch(url, { headers: this._headers() });
425
+ if (!res.ok) {
426
+ const text = await res.text().catch(() => "");
427
+ throw new Error(`GitLab ${res.status} on jobs/${jobId}/trace: ${text}`);
428
+ }
429
+ return res.text();
430
+ }
431
+
432
+ // ─── GitLab-specific extras ───────────────────────────────────────────────
433
+
434
+ async getPipelinesBySha(sha) {
435
+ const raw = await this._req(
436
+ `/projects/${this._enc()}/pipelines?sha=${encodeURIComponent(sha)}&per_page=5`,
437
+ );
438
+ return raw.map((p) => this._pipeline(p));
439
+ }
440
+
441
+ async getPipelinesByRef(ref) {
442
+ const raw = await this._req(
443
+ `/projects/${this._enc()}/pipelines?ref=${encodeURIComponent(ref)}&per_page=5`,
444
+ );
445
+ return raw.map((p) => this._pipeline(p));
446
+ }
447
+
448
+ async listBranchesByPrefix(prefix) {
449
+ const raw = await this._req(
450
+ `/projects/${this._enc()}/repository/branches?search=^${encodeURIComponent(prefix)}&per_page=100`,
451
+ );
452
+ return raw.map((b) => b.name).filter((n) => n.startsWith(prefix));
453
+ }
454
+
455
+ async compareRefs(fromRef, toRef) {
456
+ try {
457
+ const raw = await this._req(
458
+ `/projects/${this._enc()}/repository/compare?from=${encodeURIComponent(fromRef)}&to=${encodeURIComponent(toRef)}&straight=true`,
459
+ );
460
+ return { aheadCount: Array.isArray(raw.commits) ? raw.commits.length : 0 };
461
+ } catch {
462
+ return null;
463
+ }
464
+ }
465
+
466
+ async listRecentMergedMRIssueLinks(limit = 50) {
467
+ const raw = await this._req(
468
+ `/projects/${this._enc()}/merge_requests?state=merged&order_by=updated_at&per_page=${limit}`,
469
+ );
470
+ // Don't pre-filter on `closedIssueIids` — the route uses `sourceBranch`
471
+ // as a fallback signal when the MR description lacks a closing keyword.
472
+ return raw.map((mr) => ({
473
+ mrIid: mr.iid,
474
+ webUrl: mr.web_url,
475
+ mergedAt: mr.merged_at ?? null,
476
+ sourceBranch: mr.source_branch ?? null,
477
+ closedIssueIids: parseClosingIssueRefs(mr.description ?? ""),
478
+ }));
479
+ }
480
+
481
+ // ─── Environments ─────────────────────────────────────────────────────────
482
+
483
+ /** @param {object} raw @returns {import('./base.js').NormalEnvironment} */
484
+ _environment(raw) {
485
+ return {
486
+ id: raw.id,
487
+ name: raw.name,
488
+ state: raw.state ?? "unknown",
489
+ external_url: raw.external_url ?? null,
490
+ // GitLab puts the branch ref on the last deployment's deployable.
491
+ ref: raw.last_deployment?.deployable?.ref
492
+ ?? raw.last_deployment?.ref
493
+ ?? null,
494
+ updated_at: raw.updated_at ?? null,
495
+ web_url: raw.web_url ?? null,
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Pull environments for a given branch. GitLab's `/environments` endpoint
501
+ * doesn't filter by ref, so we fetch available+stopping envs and match on
502
+ * the last-deployment ref. Limits per_page to 100 — ephemeral envs should
503
+ * never grow past that per project.
504
+ */
505
+ async listEnvironmentsForBranch(branchName) {
506
+ if (!branchName) return [];
507
+ const raw = await this._req(
508
+ `/projects/${this._enc()}/environments?states=available&per_page=100`,
509
+ );
510
+ // GitLab's list response omits last_deployment — fetch detail per-env to
511
+ // learn the branch. Concurrency is bounded by the per_page=100 cap.
512
+ const detailed = await Promise.all(
513
+ raw.map((e) =>
514
+ this._req(`/projects/${this._enc()}/environments/${e.id}`).catch(() => null),
515
+ ),
516
+ );
517
+ return detailed
518
+ .filter(Boolean)
519
+ .map((e) => this._environment(e))
520
+ .filter((e) => e.ref === branchName);
521
+ }
522
+
523
+ async stopEnvironment(envId) {
524
+ await this._req(
525
+ `/projects/${this._enc()}/environments/${envId}/stop`,
526
+ { method: "POST" },
527
+ );
528
+ }
529
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * server/forge/core/providers/index.js
3
+ *
4
+ * Factory that creates the correct ScmProvider for a given repo string.
5
+ * Provider type is resolved in this order:
6
+ * 1. Per-repo prefix — "github:owner/repo", "azuredevops:org/project/repo"
7
+ * 2. SCM_PROVIDER env var — "gitlab" | "github" | "azuredevops"
8
+ * 3. Default: gitlab
9
+ */
10
+
11
+ import { GitLabProvider } from "./gitlab.js";
12
+ import { GitHubProvider } from "./github.js";
13
+ import { AzureDevOpsProvider } from "./azuredevops.js";
14
+
15
+ /**
16
+ * @param {string} [repo] Raw repo string from request (may carry a "type:" prefix)
17
+ * @returns {import('./base.js').ScmProvider}
18
+ */
19
+ export function createProvider(repo) {
20
+ // Strip optional "provider:" prefix, e.g. "github:owner/repo"
21
+ let type = process.env.SCM_PROVIDER ?? "gitlab";
22
+ let repoPath = repo;
23
+
24
+ if (repo) {
25
+ const colonIdx = repo.indexOf(":");
26
+ if (colonIdx > 0) {
27
+ const prefix = repo.slice(0, colonIdx).toLowerCase();
28
+ if (["gitlab", "github", "azuredevops"].includes(prefix)) {
29
+ type = prefix;
30
+ repoPath = repo.slice(colonIdx + 1);
31
+ }
32
+ }
33
+ }
34
+
35
+ switch (type.toLowerCase()) {
36
+ case "github":
37
+ return new GitHubProvider(repoPath);
38
+ case "azuredevops":
39
+ case "azure":
40
+ return new AzureDevOpsProvider(repoPath);
41
+ default:
42
+ return new GitLabProvider(repoPath);
43
+ }
44
+ }
45
+
46
+ export { GitLabProvider, GitHubProvider, AzureDevOpsProvider };
47
+ export { ScmProvider } from "./base.js";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * server/forge/core/quotaClient.js
3
+ *
4
+ * One-off probe of the Anthropic Messages API to read the plan-level
5
+ * rate-limit headers. Used by the "Refresh quota" button in the UsageWidget.
6
+ *
7
+ * The rest of Forge still runs through `claude` CLI (subscription billing);
8
+ * this probe is the only place that spends API credits, and only when the
9
+ * user explicitly asks for it. Cost is negligible — one request with
10
+ * `max_tokens=1` against Haiku.
11
+ */
12
+
13
+ const PROBE_MODEL = "claude-haiku-4-5-20251001";
14
+
15
+ function num(v) {
16
+ const n = Number(v);
17
+ return Number.isFinite(n) ? n : null;
18
+ }
19
+
20
+ /**
21
+ * @param {Headers} headers
22
+ * @returns {null | {
23
+ * tokensLimit: number|null,
24
+ * tokensRemaining: number|null,
25
+ * tokensReset: string|null,
26
+ * requestsLimit: number|null,
27
+ * requestsRemaining: number|null,
28
+ * requestsReset: string|null,
29
+ * }}
30
+ */
31
+ export function parseRateLimit(headers) {
32
+ const rl = {
33
+ tokensLimit: num(headers.get("anthropic-ratelimit-tokens-limit")),
34
+ tokensRemaining: num(headers.get("anthropic-ratelimit-tokens-remaining")),
35
+ tokensReset: headers.get("anthropic-ratelimit-tokens-reset") ?? null,
36
+ requestsLimit: num(headers.get("anthropic-ratelimit-requests-limit")),
37
+ requestsRemaining: num(headers.get("anthropic-ratelimit-requests-remaining")),
38
+ requestsReset: headers.get("anthropic-ratelimit-requests-reset") ?? null,
39
+ };
40
+ const any =
41
+ rl.tokensLimit !== null ||
42
+ rl.tokensRemaining !== null ||
43
+ rl.tokensReset ||
44
+ rl.requestsLimit !== null ||
45
+ rl.requestsRemaining !== null ||
46
+ rl.requestsReset;
47
+ return any ? rl : null;
48
+ }
49
+
50
+ /**
51
+ * Fires one minimal Messages API request and returns the rate-limit snapshot
52
+ * from the response headers.
53
+ *
54
+ * @throws if ANTHROPIC_API_KEY is not configured, or the API rejects us.
55
+ */
56
+ export async function probeQuota() {
57
+ if (!process.env.ANTHROPIC_API_KEY) {
58
+ const err = new Error(
59
+ "Quota check requires ANTHROPIC_API_KEY to be set. Add it to your .env.",
60
+ );
61
+ err.status = 400;
62
+ throw err;
63
+ }
64
+
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(), 10_000);
67
+
68
+ let res;
69
+ try {
70
+ res = await fetch("https://api.anthropic.com/v1/messages", {
71
+ method: "POST",
72
+ headers: {
73
+ "x-api-key": process.env.ANTHROPIC_API_KEY,
74
+ "anthropic-version": "2023-06-01",
75
+ "content-type": "application/json",
76
+ },
77
+ body: JSON.stringify({
78
+ model: PROBE_MODEL,
79
+ max_tokens: 1,
80
+ messages: [{ role: "user", content: "." }],
81
+ }),
82
+ signal: controller.signal,
83
+ });
84
+ } finally {
85
+ clearTimeout(timer);
86
+ }
87
+
88
+ // Rate-limit headers are present even on 4xx/5xx responses, so grab them
89
+ // before deciding whether to throw.
90
+ const rateLimit = parseRateLimit(res.headers);
91
+
92
+ if (!res.ok) {
93
+ const text = await res.text().catch(() => "");
94
+ const err = new Error(`Anthropic API ${res.status}: ${text.slice(0, 300)}`);
95
+ err.status = res.status;
96
+ err.rateLimit = rateLimit;
97
+ throw err;
98
+ }
99
+
100
+ return { rateLimit, checkedAt: new Date().toISOString() };
101
+ }