@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,408 @@
1
+ /**
2
+ * server/forge/core/providers/azuredevops.js
3
+ *
4
+ * Azure DevOps REST API implementation of ScmProvider.
5
+ * Maps Work Items → issues, Pull Requests → MRs, Pipelines → CI.
6
+ *
7
+ * Required env vars:
8
+ * AZURE_DEVOPS_TOKEN — Personal Access Token (Base64 or raw PAT)
9
+ * AZURE_DEVOPS_ORG — organization name, e.g. "ipul-labs"
10
+ * AZURE_DEVOPS_PROJECT — team project name, e.g. "web-app"
11
+ * AZURE_DEVOPS_REPO — git repo name (often same as project)
12
+ *
13
+ * Optional:
14
+ * AZURE_DEVOPS_BASE_BRANCH — default target branch (default: "main")
15
+ */
16
+
17
+ import { getConnection } from "../configStore.js";
18
+ import { ScmProvider } from "./base.js";
19
+
20
+ export class AzureDevOpsProvider extends ScmProvider {
21
+ /**
22
+ * @param {string} [repo] "org/project/repo" — overrides env vars
23
+ */
24
+ constructor(repo) {
25
+ super();
26
+ if (repo && repo.includes("/")) {
27
+ const parts = repo.split("/");
28
+ this._org = parts[0];
29
+ this._project = parts[1];
30
+ this._repo = parts[2] ?? parts[1];
31
+ } else {
32
+ this._org = process.env.AZURE_DEVOPS_ORG ?? "";
33
+ this._project = process.env.AZURE_DEVOPS_PROJECT ?? "";
34
+ this._repo = process.env.AZURE_DEVOPS_REPO ?? this._project;
35
+ }
36
+ this._defaultBranch = process.env.AZURE_DEVOPS_BASE_BRANCH ?? "main";
37
+ this._vsBase = `https://dev.azure.com/${this._org}`;
38
+ this._vssmBase = `https://vsrm.dev.azure.com/${this._org}`;
39
+ }
40
+
41
+ get name() { return "azuredevops"; }
42
+ get supportsRebase() { return false; }
43
+
44
+ // ─── Internal helpers ──────────────────────────────────────────────────────
45
+
46
+ _headers() {
47
+ const token = getConnection("azuredevops").token;
48
+ // ADO accepts Basic auth with a PAT as the password (username can be anything)
49
+ const encoded = Buffer.from(`:${token}`).toString("base64");
50
+ return {
51
+ Authorization: `Basic ${encoded}`,
52
+ "Content-Type": "application/json",
53
+ };
54
+ }
55
+
56
+ _gitBase() {
57
+ return `${this._vsBase}/${encodeURIComponent(this._project)}/_apis/git/repositories/${encodeURIComponent(this._repo)}`;
58
+ }
59
+
60
+ _witBase() {
61
+ return `${this._vsBase}/${encodeURIComponent(this._project)}/_apis/wit`;
62
+ }
63
+
64
+ _pipelineBase() {
65
+ return `${this._vsBase}/${encodeURIComponent(this._project)}/_apis/pipelines`;
66
+ }
67
+
68
+ _buildBase() {
69
+ return `${this._vsBase}/${encodeURIComponent(this._project)}/_apis/build`;
70
+ }
71
+
72
+ async _req(url, options = {}) {
73
+ const sep = url.includes("?") ? "&" : "?";
74
+ const versioned = url.includes("api-version") ? url : `${url}${sep}api-version=7.1`;
75
+ const res = await fetch(versioned, {
76
+ ...options,
77
+ headers: { ...this._headers(), ...options.headers },
78
+ });
79
+ if (res.status === 204) return null;
80
+ if (!res.ok) {
81
+ const text = await res.text().catch(() => "");
82
+ throw new Error(`AzureDevOps ${res.status} on ${url}: ${text}`);
83
+ }
84
+ return res.json();
85
+ }
86
+
87
+ // ─── Normalizers ──────────────────────────────────────────────────────────
88
+
89
+ _issue(raw) {
90
+ const fields = raw.fields ?? {};
91
+ return {
92
+ iid: raw.id,
93
+ title: fields["System.Title"] ?? "",
94
+ description: fields["System.Description"] ?? null,
95
+ labels: (fields["System.Tags"] ?? "").split(";").map((t) => t.trim()).filter(Boolean),
96
+ author: { name: fields["System.CreatedBy"]?.displayName ?? "" },
97
+ created_at: fields["System.CreatedDate"] ?? "",
98
+ web_url: raw._links?.html?.href ?? "",
99
+ };
100
+ }
101
+
102
+ _mr(raw) {
103
+ const status = raw.status ?? "";
104
+ return {
105
+ iid: raw.pullRequestId,
106
+ title: raw.title,
107
+ web_url: `${this._vsBase}/${encodeURIComponent(this._project)}/_git/${encodeURIComponent(this._repo)}/pullrequest/${raw.pullRequestId}`,
108
+ source_branch: (raw.sourceRefName ?? "").replace("refs/heads/", ""),
109
+ target_branch: (raw.targetRefName ?? "").replace("refs/heads/", ""),
110
+ state: status === "completed" ? "merged" : status === "abandoned" ? "closed" : "opened",
111
+ has_conflicts: raw.mergeStatus === "conflicts",
112
+ rebase_in_progress: false,
113
+ merge_status: raw.mergeStatus ?? "",
114
+ detailed_merge_status: null,
115
+ draft: raw.isDraft ?? false,
116
+ head_pipeline: null,
117
+ author: { name: raw.createdBy?.displayName ?? "" },
118
+ created_at: raw.creationDate ?? "",
119
+ merged_at: status === "completed" ? (raw.closedDate ?? null) : null,
120
+ };
121
+ }
122
+
123
+ _pipeline(raw) {
124
+ return {
125
+ id: raw.id,
126
+ status: this._buildStatus(raw),
127
+ web_url: raw._links?.web?.href ?? "",
128
+ created_at: raw.queueTime ?? raw.startTime ?? "",
129
+ };
130
+ }
131
+
132
+ _buildStatus(raw) {
133
+ const result = raw.result;
134
+ if (result === "succeeded") return "success";
135
+ if (result === "failed" || result === "partiallySucceeded") return "failed";
136
+ if (result === "canceled") return "cancelled";
137
+ const status = raw.status;
138
+ if (status === "inProgress") return "running";
139
+ if (status === "notStarted") return "pending";
140
+ return status ?? "unknown";
141
+ }
142
+
143
+ _job(raw) {
144
+ return {
145
+ id: raw.id,
146
+ name: raw.name,
147
+ stage: raw.stageName ?? "",
148
+ status: this._taskStatus(raw),
149
+ web_url: raw._links?.web?.href ?? "",
150
+ duration: raw.finishTime && raw.startTime
151
+ ? Math.round((new Date(raw.finishTime) - new Date(raw.startTime)) / 1000)
152
+ : null,
153
+ };
154
+ }
155
+
156
+ _taskStatus(raw) {
157
+ const result = raw.result;
158
+ if (result === "succeeded") return "success";
159
+ if (result === "failed") return "failed";
160
+ if (result === "skipped") return "skipped";
161
+ const state = raw.state;
162
+ if (state === "inProgress") return "running";
163
+ if (state === "pending") return "pending";
164
+ return state ?? "unknown";
165
+ }
166
+
167
+ _diff(raw) {
168
+ const changeType = raw.changeType ?? "";
169
+ return {
170
+ old_path: raw.item?.path ?? "",
171
+ new_path: raw.item?.path ?? "",
172
+ diff: "", // ADO diff content requires a separate API call
173
+ new_file: changeType === "add",
174
+ renamed_file: changeType === "rename",
175
+ deleted_file: changeType === "delete",
176
+ };
177
+ }
178
+
179
+ // ─── Issues (Work Items) ──────────────────────────────────────────────────
180
+
181
+ async listOpenIssues() {
182
+ // WIQL query for open issues in the project
183
+ const wiql = {
184
+ query: `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '${this._project}' AND [System.State] <> 'Done' AND [System.State] <> 'Closed' AND [System.WorkItemType] IN ('Bug','Issue','Task','User Story') ORDER BY [System.ChangedDate] DESC`,
185
+ };
186
+ const result = await this._req(
187
+ `${this._witBase()}/wiql?$top=25`,
188
+ { method: "POST", body: JSON.stringify(wiql) },
189
+ );
190
+ const ids = (result?.workItems ?? []).map((w) => w.id);
191
+ if (ids.length === 0) return [];
192
+
193
+ const batch = await this._req(
194
+ `${this._witBase()}/workitems?ids=${ids.join(",")}&$expand=fields`,
195
+ );
196
+ return (batch?.value ?? []).map((wi) => this._issue(wi));
197
+ }
198
+
199
+ async getIssue(iid) {
200
+ const raw = await this._req(`${this._witBase()}/workitems/${iid}?$expand=fields`);
201
+ return this._issue(raw);
202
+ }
203
+
204
+ async createIssue(title, description = "") {
205
+ const patch = [
206
+ { op: "add", path: "/fields/System.Title", value: title },
207
+ { op: "add", path: "/fields/System.Description", value: description },
208
+ ];
209
+ const raw = await this._req(
210
+ `${this._witBase()}/workitems/$Issue`,
211
+ {
212
+ method: "POST",
213
+ headers: { "Content-Type": "application/json-patch+json" },
214
+ body: JSON.stringify(patch),
215
+ },
216
+ );
217
+ return this._issue(raw);
218
+ }
219
+
220
+ async updateIssue(iid, { title, description }) {
221
+ const patch = [];
222
+ if (title !== undefined) patch.push({ op: "replace", path: "/fields/System.Title", value: title });
223
+ if (description !== undefined) patch.push({ op: "replace", path: "/fields/System.Description", value: description });
224
+ const raw = await this._req(
225
+ `${this._witBase()}/workitems/${iid}`,
226
+ {
227
+ method: "PATCH",
228
+ headers: { "Content-Type": "application/json-patch+json" },
229
+ body: JSON.stringify(patch),
230
+ },
231
+ );
232
+ return this._issue(raw);
233
+ }
234
+
235
+ async closeIssue(iid) {
236
+ const patch = [{ op: "replace", path: "/fields/System.State", value: "Closed" }];
237
+ const raw = await this._req(
238
+ `${this._witBase()}/workitems/${iid}`,
239
+ {
240
+ method: "PATCH",
241
+ headers: { "Content-Type": "application/json-patch+json" },
242
+ body: JSON.stringify(patch),
243
+ },
244
+ );
245
+ return this._issue(raw);
246
+ }
247
+
248
+ // ─── Repository ───────────────────────────────────────────────────────────
249
+
250
+ async getFileTree(srcPath = "src", ref = this._defaultBranch) {
251
+ const data = await this._req(
252
+ `${this._gitBase()}/trees/${ref}?recursive=true&$scopePath=/${srcPath}`,
253
+ );
254
+ return (data?.treeEntries ?? [])
255
+ .filter((item) => item.gitObjectType === "blob")
256
+ .filter((item) => /\.(ts|tsx|js|jsx|json|css|yaml|yml|md)$/.test(item.relativePath))
257
+ .map((item) => item.relativePath);
258
+ }
259
+
260
+ async getFileContent(filePath, ref = this._defaultBranch) {
261
+ const encoded = encodeURIComponent(filePath);
262
+ const url = `${this._gitBase()}/items?path=${encoded}&versionDescriptor.version=${ref}&versionDescriptor.versionType=branch&$format=text&api-version=7.1`;
263
+ const res = await fetch(url, { headers: this._headers() });
264
+ if (!res.ok) {
265
+ const text = await res.text().catch(() => "");
266
+ throw new Error(`AzureDevOps ${res.status} on items/${filePath}: ${text}`);
267
+ }
268
+ return res.text();
269
+ }
270
+
271
+ // ─── Mutations ────────────────────────────────────────────────────────────
272
+
273
+ async createBranch(branchName, ref = this._defaultBranch) {
274
+ // Get current SHA for ref
275
+ const refs = await this._req(`${this._gitBase()}/refs?filter=heads/${ref}`);
276
+ const sha = refs?.value?.[0]?.objectId;
277
+ if (!sha) throw new Error(`AzureDevOps: could not resolve ref ${ref}`);
278
+
279
+ return this._req(`${this._gitBase()}/refs`, {
280
+ method: "POST",
281
+ body: JSON.stringify([
282
+ {
283
+ name: `refs/heads/${branchName}`,
284
+ newObjectId: sha,
285
+ oldObjectId: "0000000000000000000000000000000000000000",
286
+ },
287
+ ]),
288
+ });
289
+ }
290
+
291
+ async commitFiles(branch, message, actions) {
292
+ const changes = actions.map((action) => ({
293
+ changeType: action.action === "create" ? "add" : action.action === "delete" ? "delete" : "edit",
294
+ item: { path: `/${action.file_path}` },
295
+ newContent: action.action !== "delete"
296
+ ? { content: action.content, contentType: "rawtext" }
297
+ : undefined,
298
+ }));
299
+
300
+ // Get current commit SHA
301
+ const refs = await this._req(`${this._gitBase()}/refs?filter=heads/${branch}`);
302
+ const oldSha = refs?.value?.[0]?.objectId ?? "0000000000000000000000000000000000000000";
303
+
304
+ return this._req(`${this._gitBase()}/pushes`, {
305
+ method: "POST",
306
+ body: JSON.stringify({
307
+ refUpdates: [{ name: `refs/heads/${branch}`, oldObjectId: oldSha }],
308
+ commits: [{ comment: message, changes }],
309
+ }),
310
+ });
311
+ }
312
+
313
+ async createMR({ sourceBranch, targetBranch = this._defaultBranch, title, description = "" }) {
314
+ const raw = await this._req(`${this._gitBase()}/pullrequests`, {
315
+ method: "POST",
316
+ body: JSON.stringify({
317
+ sourceRefName: `refs/heads/${sourceBranch}`,
318
+ targetRefName: `refs/heads/${targetBranch}`,
319
+ title,
320
+ description,
321
+ }),
322
+ });
323
+ return this._mr(raw);
324
+ }
325
+
326
+ // ─── PR queries ───────────────────────────────────────────────────────────
327
+
328
+ async listAllForgeMRs(state = "open") {
329
+ const adoStatus =
330
+ state === "merged" ? "completed"
331
+ : state === "closed" ? "abandoned"
332
+ : "active";
333
+ const data = await this._req(
334
+ `${this._gitBase()}/pullrequests?searchCriteria.status=${adoStatus}&$top=50`,
335
+ );
336
+ return (data?.value ?? [])
337
+ .filter((pr) => pr.sourceRefName?.includes("refs/heads/forge/"))
338
+ .map((pr) => this._mr(pr));
339
+ }
340
+
341
+ async listForgeMRsForIssue(issueIid) {
342
+ const data = await this._req(
343
+ `${this._gitBase()}/pullrequests?searchCriteria.status=active&$top=50`,
344
+ );
345
+ return (data?.value ?? [])
346
+ .filter((pr) => pr.sourceRefName?.includes(`refs/heads/forge/issue-${issueIid}-`))
347
+ .map((pr) => this._mr(pr));
348
+ }
349
+
350
+ async listMergedForgeMRsForIssue(issueIid) {
351
+ const data = await this._req(
352
+ `${this._gitBase()}/pullrequests?searchCriteria.status=completed&$top=50`,
353
+ );
354
+ return (data?.value ?? [])
355
+ .filter((pr) => pr.sourceRefName?.includes(`refs/heads/forge/issue-${issueIid}-`))
356
+ .map((pr) => this._mr(pr));
357
+ }
358
+
359
+ async getMR(mrIid) {
360
+ const raw = await this._req(`${this._gitBase()}/pullrequests/${mrIid}`);
361
+ return this._mr(raw);
362
+ }
363
+
364
+ async getMRDiffs(mrIid) {
365
+ const data = await this._req(
366
+ `${this._gitBase()}/pullrequests/${mrIid}/iterations/1/changes`,
367
+ );
368
+ return (data?.changeEntries ?? []).map((c) => this._diff(c));
369
+ }
370
+
371
+ // ─── CI / Pipelines ───────────────────────────────────────────────────────
372
+
373
+ async getMRPipelines(mrIid) {
374
+ // Get PR statuses which link to builds
375
+ const data = await this._req(
376
+ `${this._gitBase()}/pullrequests/${mrIid}/statuses`,
377
+ );
378
+ const buildIds = (data?.value ?? [])
379
+ .map((s) => s.targetUrl?.match(/buildId=(\d+)/)?.[1])
380
+ .filter(Boolean)
381
+ .map(Number);
382
+
383
+ if (buildIds.length === 0) return [];
384
+ return Promise.all(buildIds.map((id) => this.getPipeline(id)));
385
+ }
386
+
387
+ async getPipeline(pipelineId) {
388
+ const raw = await this._req(`${this._buildBase()}/builds/${pipelineId}`);
389
+ return this._pipeline(raw);
390
+ }
391
+
392
+ async getPipelineJobs(pipelineId) {
393
+ const data = await this._req(`${this._buildBase()}/builds/${pipelineId}/timeline`);
394
+ return (data?.records ?? [])
395
+ .filter((r) => r.type === "Task")
396
+ .map((r) => this._job(r));
397
+ }
398
+
399
+ async getJobTrace(jobId) {
400
+ const url = `${this._buildBase()}/builds/${jobId}/logs/1?api-version=7.1`;
401
+ const res = await fetch(url, { headers: this._headers() });
402
+ if (!res.ok) {
403
+ const text = await res.text().catch(() => "");
404
+ throw new Error(`AzureDevOps ${res.status} on builds/${jobId}/logs: ${text}`);
405
+ }
406
+ return res.text();
407
+ }
408
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * server/forge/core/providers/base.js
3
+ *
4
+ * Abstract base class that defines the interface every SCM provider must
5
+ * implement. Methods throw "not implemented" by default so subclasses get
6
+ * a clear error rather than a silent no-op when something is missing.
7
+ *
8
+ * All data shapes returned by provider methods are normalised so that
9
+ * ForgeAgent and routes.js never need to know which provider is active.
10
+ *
11
+ * Normalised shapes (JSDoc):
12
+ *
13
+ * @typedef {{ iid: number, title: string, description: string|null,
14
+ * labels: string[], author: { name: string },
15
+ * created_at: string, web_url: string }} NormalIssue
16
+ *
17
+ * @typedef {{ iid: number, title: string, web_url: string,
18
+ * source_branch: string, target_branch: string,
19
+ * state: string, has_conflicts: boolean,
20
+ * rebase_in_progress: boolean, merge_status: string,
21
+ * detailed_merge_status: string|null,
22
+ * draft: boolean,
23
+ * head_pipeline: { status: string, web_url: string }|null,
24
+ * author: { name: string }, created_at: string,
25
+ * merged_at: string|null,
26
+ * merge_commit_sha: string|null }} NormalMR
27
+ *
28
+ * @typedef {{ id: number, status: string, web_url: string,
29
+ * created_at: string }} NormalPipeline
30
+ *
31
+ * @typedef {{ id: number, name: string, stage: string, status: string,
32
+ * web_url: string, duration: number|null }} NormalJob
33
+ *
34
+ * @typedef {{ old_path: string, new_path: string, diff: string,
35
+ * new_file: boolean, renamed_file: boolean,
36
+ * deleted_file: boolean }} NormalDiff
37
+ *
38
+ * @typedef {{ action: 'create'|'modify'|'delete',
39
+ * file_path: string, content: string }} CommitAction
40
+ *
41
+ * @typedef {{ id: number, name: string,
42
+ * state: 'available'|'stopping'|'stopped'|string,
43
+ * external_url: string|null, ref: string|null,
44
+ * updated_at: string|null, web_url: string|null }} NormalEnvironment
45
+ */
46
+
47
+ export class ScmProvider {
48
+ /** @returns {string} Provider identifier */
49
+ get name() { return "base"; }
50
+
51
+ /** @returns {boolean} Whether the provider supports server-side rebase */
52
+ get supportsRebase() { return false; }
53
+
54
+ // ─── Issues ────────────────────────────────────────────────────────────────
55
+
56
+ /** @returns {Promise<NormalIssue[]>} */
57
+ async listOpenIssues() { throw new Error(`${this.name}: listOpenIssues not implemented`); }
58
+
59
+ /** @param {number|string} iid @returns {Promise<NormalIssue>} */
60
+ async getIssue(iid) { throw new Error(`${this.name}: getIssue not implemented`); }
61
+
62
+ /** @returns {Promise<NormalIssue>} */
63
+ async createIssue(title, description) { throw new Error(`${this.name}: createIssue not implemented`); }
64
+
65
+ /** @returns {Promise<NormalIssue>} */
66
+ async updateIssue(iid, { title, description }) { throw new Error(`${this.name}: updateIssue not implemented`); }
67
+
68
+ /** @returns {Promise<NormalIssue>} */
69
+ async closeIssue(iid) { throw new Error(`${this.name}: closeIssue not implemented`); }
70
+
71
+ // ─── Repository ────────────────────────────────────────────────────────────
72
+
73
+ /** @returns {Promise<string[]>} File paths */
74
+ async getFileTree(srcPath, ref) { throw new Error(`${this.name}: getFileTree not implemented`); }
75
+
76
+ /** @returns {Promise<string>} File content */
77
+ async getFileContent(filePath, ref) { throw new Error(`${this.name}: getFileContent not implemented`); }
78
+
79
+ // ─── Mutations ─────────────────────────────────────────────────────────────
80
+
81
+ async createBranch(branchName, ref) { throw new Error(`${this.name}: createBranch not implemented`); }
82
+
83
+ /** @param {string} branchName @returns {Promise<void>} */
84
+ async deleteBranch(branchName) { throw new Error(`${this.name}: deleteBranch not implemented`); }
85
+
86
+ /** @param {CommitAction[]} actions */
87
+ async commitFiles(branch, message, actions) { throw new Error(`${this.name}: commitFiles not implemented`); }
88
+
89
+ /** @returns {Promise<NormalMR>} */
90
+ async createMR({ sourceBranch, targetBranch, title, description }) { throw new Error(`${this.name}: createMR not implemented`); }
91
+
92
+ // ─── MR / PR queries ───────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * @param {"open"|"merged"|"closed"} [state="open"] Lifecycle filter
96
+ * @returns {Promise<NormalMR[]>} Forge-created MRs in the requested state
97
+ */
98
+ async listAllForgeMRs(_state = "open") { throw new Error(`${this.name}: listAllForgeMRs not implemented`); }
99
+
100
+ /** @returns {Promise<NormalMR[]>} Open Forge MRs for a specific issue */
101
+ async listForgeMRsForIssue(issueIid) { throw new Error(`${this.name}: listForgeMRsForIssue not implemented`); }
102
+
103
+ /** @returns {Promise<NormalMR[]>} Merged Forge MRs for a specific issue */
104
+ async listMergedForgeMRsForIssue(issueIid) { throw new Error(`${this.name}: listMergedForgeMRsForIssue not implemented`); }
105
+
106
+ /** @returns {Promise<NormalMR>} */
107
+ async getMR(mrIid) { throw new Error(`${this.name}: getMR not implemented`); }
108
+
109
+ /** @returns {Promise<NormalDiff[]>} */
110
+ async getMRDiffs(mrIid) { throw new Error(`${this.name}: getMRDiffs not implemented`); }
111
+
112
+ /** @returns {Promise<void>} */
113
+ async rebaseMR(mrIid) { throw new Error(`${this.name}: rebase not supported`); }
114
+
115
+ /** Merge the MR immediately. @returns {Promise<void>} */
116
+ async mergeMR(_mrIid) { throw new Error(`${this.name}: mergeMR not supported`); }
117
+
118
+ /** Close the MR without merging. @returns {Promise<void>} */
119
+ async closeMR(_mrIid) { throw new Error(`${this.name}: closeMR not supported`); }
120
+
121
+ /** Reopen a closed MR. @returns {Promise<void>} */
122
+ async reopenMR(_mrIid) { throw new Error(`${this.name}: reopenMR not supported`); }
123
+
124
+ /**
125
+ * Toggle draft / work-in-progress state on the MR.
126
+ * @param {number} _mrIid
127
+ * @param {boolean} _draft true → mark as draft, false → mark as ready
128
+ * @returns {Promise<void>}
129
+ */
130
+ async setMRDraft(_mrIid, _draft) { throw new Error(`${this.name}: setMRDraft not supported`); }
131
+
132
+ // ─── CI / Pipelines ────────────────────────────────────────────────────────
133
+
134
+ /** @returns {Promise<NormalPipeline[]>} */
135
+ async getMRPipelines(mrIid) { throw new Error(`${this.name}: getMRPipelines not implemented`); }
136
+
137
+ /** @returns {Promise<NormalPipeline>} */
138
+ async getPipeline(pipelineId) { throw new Error(`${this.name}: getPipeline not implemented`); }
139
+
140
+ /** @returns {Promise<NormalJob[]>} */
141
+ async getPipelineJobs(pipelineId) { throw new Error(`${this.name}: getPipelineJobs not implemented`); }
142
+
143
+ /** @returns {Promise<string>} Plain-text log */
144
+ async getJobTrace(jobId) { throw new Error(`${this.name}: getJobTrace not implemented`); }
145
+
146
+ /** @returns {Promise<NormalPipeline[]>} Pipelines that ran on a specific commit SHA */
147
+ async getPipelinesBySha(_sha) { return []; }
148
+
149
+ /** @returns {Promise<NormalPipeline[]>} Pipelines that ran on a specific branch ref */
150
+ async getPipelinesByRef(_ref) { return []; }
151
+
152
+ /** @returns {Promise<string[]>} Branch names starting with the given prefix */
153
+ async listBranchesByPrefix(_prefix) { return []; }
154
+
155
+ /**
156
+ * Count commits reachable from `toRef` but not from `fromRef`. Used to detect
157
+ * stale / empty forge branches (aheadCount === 0 means the branch has no
158
+ * pending changes vs. main). Return `null` when the provider can't compute it
159
+ * so callers can distinguish "no divergence" from "unknown".
160
+ *
161
+ * @param {string} _fromRef base ref (typically "main")
162
+ * @param {string} _toRef ref under test (e.g. "forge/issue-10-…")
163
+ * @returns {Promise<{aheadCount: number}|null>}
164
+ */
165
+ async compareRefs(_fromRef, _toRef) { return null; }
166
+
167
+ /**
168
+ * Scan recently merged MRs so callers can badge open issues whose fix
169
+ * already shipped. Two attribution paths the consumer can use:
170
+ * 1. `closedIssueIids` — issues named by `Closes #N` / `Fixes #N` /
171
+ * `Resolves #N` trailers in the MR description (primary signal).
172
+ * 2. `sourceBranch` — the MR's source branch; lets the consumer recover
173
+ * issue IDs from branch names like `forge/issue-42-foo` when the MR
174
+ * description has no closing keyword.
175
+ * Returned entries are NOT pre-filtered on `closedIssueIids` — drop noise
176
+ * in the caller if you only want one of the two signals. One bulk call per
177
+ * issue-list render — do NOT call per-issue.
178
+ *
179
+ * @param {number} _limit cap on MRs to scan (default 50)
180
+ * @returns {Promise<Array<{mrIid: number, webUrl: string, mergedAt: string|null, sourceBranch: string|null, closedIssueIids: number[]}>>}
181
+ */
182
+ async listRecentMergedMRIssueLinks(_limit) { return []; }
183
+
184
+ // ─── Environments (ephemeral review apps) ─────────────────────────────────
185
+
186
+ /**
187
+ * List environments attached to a branch. Used to surface per-issue
188
+ * ephemeral URLs + status next to the pipeline on the issue detail view.
189
+ *
190
+ * Implementations that don't support environments should return `[]` so
191
+ * the UI simply doesn't render the section.
192
+ *
193
+ * @param {string} _branchName
194
+ * @returns {Promise<NormalEnvironment[]>}
195
+ */
196
+ async listEnvironmentsForBranch(_branchName) { return []; }
197
+
198
+ /**
199
+ * Trigger teardown of an environment. GitLab supports this natively via the
200
+ * environments/:id/stop action; GitHub has no equivalent and typically
201
+ * needs a repository_dispatch event to a cleanup workflow. Implementations
202
+ * that can't stop should throw so the caller can surface the limitation.
203
+ *
204
+ * @param {number|string} _envId
205
+ * @returns {Promise<void>}
206
+ */
207
+ async stopEnvironment(_envId) { throw new Error(`${this.name}: stopEnvironment not implemented`); }
208
+ }