@mastra/factory 0.2.2-alpha.0 → 0.2.2-alpha.2

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 (174) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/auth.d.ts +2 -2
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +401 -314
  5. package/dist/auth.js.map +1 -1
  6. package/dist/capabilities/connection.js +1 -1
  7. package/dist/capabilities/intake.js +1 -1
  8. package/dist/capabilities/version-control.js +1 -1
  9. package/dist/factory.d.ts.map +1 -1
  10. package/dist/factory.js +441 -39455
  11. package/dist/factory.js.map +1 -1
  12. package/dist/index.js +2 -39466
  13. package/dist/integrations/base.js +1 -1
  14. package/dist/integrations/github/config.js +36 -22
  15. package/dist/integrations/github/config.js.map +1 -1
  16. package/dist/integrations/github/factory-session.js +33 -37
  17. package/dist/integrations/github/factory-session.js.map +1 -1
  18. package/dist/integrations/github/integration.js +1037 -3624
  19. package/dist/integrations/github/integration.js.map +1 -1
  20. package/dist/integrations/github/issue-triage.js +71 -71
  21. package/dist/integrations/github/issue-triage.js.map +1 -1
  22. package/dist/integrations/github/pat.js +47 -36
  23. package/dist/integrations/github/pat.js.map +1 -1
  24. package/dist/integrations/github/provenance.js +72 -101
  25. package/dist/integrations/github/provenance.js.map +1 -1
  26. package/dist/integrations/github/routes.d.ts.map +1 -1
  27. package/dist/integrations/github/routes.js +1272 -1833
  28. package/dist/integrations/github/routes.js.map +1 -1
  29. package/dist/integrations/github/rules.js +203 -471
  30. package/dist/integrations/github/rules.js.map +1 -1
  31. package/dist/integrations/github/sandbox.d.ts +2 -0
  32. package/dist/integrations/github/sandbox.d.ts.map +1 -1
  33. package/dist/integrations/github/sandbox.js +401 -326
  34. package/dist/integrations/github/sandbox.js.map +1 -1
  35. package/dist/integrations/github/session-subscriptions.js +165 -223
  36. package/dist/integrations/github/session-subscriptions.js.map +1 -1
  37. package/dist/integrations/github/subscriptions.js +43 -51
  38. package/dist/integrations/github/subscriptions.js.map +1 -1
  39. package/dist/integrations/github/token-refresh.js +15 -18
  40. package/dist/integrations/github/token-refresh.js.map +1 -1
  41. package/dist/integrations/github/webhook.js +359 -343
  42. package/dist/integrations/github/webhook.js.map +1 -1
  43. package/dist/integrations/linear/agent-tools.js +77 -100
  44. package/dist/integrations/linear/agent-tools.js.map +1 -1
  45. package/dist/integrations/linear/integration.js +611 -1309
  46. package/dist/integrations/linear/integration.js.map +1 -1
  47. package/dist/integrations/linear/routes.js +227 -230
  48. package/dist/integrations/linear/routes.js.map +1 -1
  49. package/dist/integrations/linear/rules.js +126 -346
  50. package/dist/integrations/linear/rules.js.map +1 -1
  51. package/dist/integrations/linear/storage.js +1 -1
  52. package/dist/integrations/platform/api-client.js +131 -135
  53. package/dist/integrations/platform/api-client.js.map +1 -1
  54. package/dist/integrations/platform/github/event-worker.js +275 -573
  55. package/dist/integrations/platform/github/event-worker.js.map +1 -1
  56. package/dist/integrations/platform/github/integration.d.ts.map +1 -1
  57. package/dist/integrations/platform/github/integration.js +845 -3942
  58. package/dist/integrations/platform/github/integration.js.map +1 -1
  59. package/dist/integrations/platform/linear/integration.js +457 -1309
  60. package/dist/integrations/platform/linear/integration.js.map +1 -1
  61. package/dist/integrations/workos/integration.js +80 -85
  62. package/dist/integrations/workos/integration.js.map +1 -1
  63. package/dist/routes/config.d.ts.map +1 -1
  64. package/dist/routes/config.js +933 -915
  65. package/dist/routes/config.js.map +1 -1
  66. package/dist/routes/custom-provider-source.js +110 -93
  67. package/dist/routes/custom-provider-source.js.map +1 -1
  68. package/dist/routes/fs.js +401 -349
  69. package/dist/routes/fs.js.map +1 -1
  70. package/dist/routes/intake.js +165 -165
  71. package/dist/routes/intake.js.map +1 -1
  72. package/dist/routes/oauth.js +447 -25166
  73. package/dist/routes/oauth.js.map +1 -1
  74. package/dist/routes/projects.js +437 -376
  75. package/dist/routes/projects.js.map +1 -1
  76. package/dist/routes/provider-credentials.js +76 -58
  77. package/dist/routes/provider-credentials.js.map +1 -1
  78. package/dist/routes/route.js +12 -8
  79. package/dist/routes/route.js.map +1 -1
  80. package/dist/routes/skills.js +160 -173
  81. package/dist/routes/skills.js.map +1 -1
  82. package/dist/routes/surface.js +250 -28618
  83. package/dist/routes/surface.js.map +1 -1
  84. package/dist/routes/tenant-credentials.js +141 -166
  85. package/dist/routes/tenant-credentials.js.map +1 -1
  86. package/dist/routes/work-items.js +654 -862
  87. package/dist/routes/work-items.js.map +1 -1
  88. package/dist/rules/binding-context.js +23 -35
  89. package/dist/rules/binding-context.js.map +1 -1
  90. package/dist/rules/defaults.js +200 -309
  91. package/dist/rules/defaults.js.map +1 -1
  92. package/dist/rules/dispatcher.d.ts.map +1 -1
  93. package/dist/rules/dispatcher.js +438 -677
  94. package/dist/rules/dispatcher.js.map +1 -1
  95. package/dist/rules/index.js +5 -599
  96. package/dist/rules/processor.js +344 -596
  97. package/dist/rules/processor.js.map +1 -1
  98. package/dist/rules/resolve.js +21 -18
  99. package/dist/rules/resolve.js.map +1 -1
  100. package/dist/rules/start-coordinator.js +168 -157
  101. package/dist/rules/start-coordinator.js.map +1 -1
  102. package/dist/rules/tools.js +50 -85
  103. package/dist/rules/tools.js.map +1 -1
  104. package/dist/rules/transition-service.js +183 -454
  105. package/dist/rules/transition-service.js.map +1 -1
  106. package/dist/rules/types.js +33 -29
  107. package/dist/rules/types.js.map +1 -1
  108. package/dist/rules/validation.js +266 -281
  109. package/dist/rules/validation.js.map +1 -1
  110. package/dist/sandbox/fleet.js +272 -250
  111. package/dist/sandbox/fleet.js.map +1 -1
  112. package/dist/sandbox/reattach.js +13 -6
  113. package/dist/sandbox/reattach.js.map +1 -1
  114. package/dist/server-error.js +12 -16
  115. package/dist/server-error.js.map +1 -1
  116. package/dist/skills/service.js +30 -35
  117. package/dist/skills/service.js.map +1 -1
  118. package/dist/spa-static.d.ts.map +1 -1
  119. package/dist/spa-static.js +91 -64
  120. package/dist/spa-static.js.map +1 -1
  121. package/dist/state-signing.js +73 -44
  122. package/dist/state-signing.js.map +1 -1
  123. package/dist/storage/domains/audit/agent-audit.js +60 -59
  124. package/dist/storage/domains/audit/agent-audit.js.map +1 -1
  125. package/dist/storage/domains/audit/base.js +169 -112
  126. package/dist/storage/domains/audit/base.js.map +1 -1
  127. package/dist/storage/domains/audit/domain.js +155 -153
  128. package/dist/storage/domains/audit/domain.js.map +1 -1
  129. package/dist/storage/domains/credentials/base.js +259 -243
  130. package/dist/storage/domains/credentials/base.js.map +1 -1
  131. package/dist/storage/domains/custom-providers/base.js +138 -143
  132. package/dist/storage/domains/custom-providers/base.js.map +1 -1
  133. package/dist/storage/domains/intake/base.js +75 -57
  134. package/dist/storage/domains/intake/base.js.map +1 -1
  135. package/dist/storage/domains/integrations/base.js +310 -245
  136. package/dist/storage/domains/integrations/base.js.map +1 -1
  137. package/dist/storage/domains/memory-settings/base.js +117 -102
  138. package/dist/storage/domains/memory-settings/base.js.map +1 -1
  139. package/dist/storage/domains/model-packs/base.js +89 -88
  140. package/dist/storage/domains/model-packs/base.js.map +1 -1
  141. package/dist/storage/domains/projects/base.js +97 -92
  142. package/dist/storage/domains/projects/base.js.map +1 -1
  143. package/dist/storage/domains/queue-health/base.js +111 -76
  144. package/dist/storage/domains/queue-health/base.js.map +1 -1
  145. package/dist/storage/domains/source-control/base.d.ts +9 -0
  146. package/dist/storage/domains/source-control/base.d.ts.map +1 -1
  147. package/dist/storage/domains/source-control/base.js +777 -702
  148. package/dist/storage/domains/source-control/base.js.map +1 -1
  149. package/dist/storage/domains/source-control/inmemory.d.ts +4 -0
  150. package/dist/storage/domains/source-control/inmemory.d.ts.map +1 -1
  151. package/dist/storage/domains/source-control/inmemory.js +322 -344
  152. package/dist/storage/domains/source-control/inmemory.js.map +1 -1
  153. package/dist/storage/domains/work-items/base.d.ts.map +1 -1
  154. package/dist/storage/domains/work-items/base.js +1266 -1132
  155. package/dist/storage/domains/work-items/base.js.map +1 -1
  156. package/dist/storage/domains/work-items/metrics.js +202 -184
  157. package/dist/storage/domains/work-items/metrics.js.map +1 -1
  158. package/dist/timing.d.ts +15 -0
  159. package/dist/timing.d.ts.map +1 -0
  160. package/dist/timing.js +34 -0
  161. package/dist/timing.js.map +1 -0
  162. package/dist/workspace.d.ts +2 -3
  163. package/dist/workspace.d.ts.map +1 -1
  164. package/dist/workspace.js +236 -438
  165. package/dist/workspace.js.map +1 -1
  166. package/factory-skills/factory-review/SKILL.md +9 -2
  167. package/package.json +9 -9
  168. package/dist/capabilities/connection.js.map +0 -1
  169. package/dist/capabilities/intake.js.map +0 -1
  170. package/dist/capabilities/version-control.js.map +0 -1
  171. package/dist/index.js.map +0 -1
  172. package/dist/integrations/base.js.map +0 -1
  173. package/dist/integrations/linear/storage.js.map +0 -1
  174. package/dist/rules/index.js.map +0 -1
@@ -1,1870 +1,1309 @@
1
- // src/integrations/github/routes.ts
2
- import { randomUUID } from "crypto";
1
+ import { clearGithubPat, getGithubPat, getGithubPatStatus, setGithubPat } from "./pat.js";
2
+ import { listPullRequestSubscriptionsForThread, subscribeToPullRequest } from "./subscriptions.js";
3
+ import { SandboxBudgetError } from "../../sandbox/fleet.js";
4
+ import { getGithubFeatureDiagnostics, isGithubFeatureEnabled } from "./config.js";
5
+ import { MaterializeError, WorktreeError, commitAll, computeWorktreePath, ensureProjectSandbox, isValidGitRef as isValidGitRef$1, materializeRepo, pushBranch, teardownProjectSandbox } from "./sandbox.js";
6
+ import { handleGithubWebhook } from "./webhook.js";
3
7
  import { registerApiRoute } from "@mastra/core/server";
8
+ import { randomUUID } from "crypto";
4
9
  import { streamSSE } from "hono/streaming";
5
-
6
- // src/sandbox/fleet.ts
7
- import path from "path";
8
- function reportProgress(onProgress, event) {
9
- if (!onProgress) return;
10
- try {
11
- onProgress(event);
12
- } catch {
13
- }
14
- }
15
- var SandboxBudgetError = class extends Error {
16
- constructor(max) {
17
- super(
18
- `Sandbox budget exceeded: this server already has ${max} active sandbox(es), the configured per-replica maximum. Close an existing repository's sandbox and try again.`
19
- );
20
- this.max = max;
21
- this.name = "SandboxBudgetError";
22
- }
23
- max;
24
- code = "sandbox-budget-exceeded";
25
- };
26
-
27
- // src/integrations/github/config.ts
28
- var GITHUB_APP_ENV_VARS = [
29
- "GITHUB_APP_ID",
30
- "GITHUB_APP_PRIVATE_KEY",
31
- "GITHUB_APP_CLIENT_ID",
32
- "GITHUB_APP_CLIENT_SECRET",
33
- "GITHUB_APP_SLUG"
34
- ];
35
- function isGithubFeatureEnabled(options) {
36
- return options.github !== void 0 && options.auth.enabled();
37
- }
38
- function getGithubFeatureDiagnostics(options) {
39
- const { github, auth, appDbConfigured, stateSigner, fleet } = options;
40
- return {
41
- githubAppConfigured: github !== void 0,
42
- factoryAuthEnabled: auth.enabled(),
43
- appDbConfigured,
44
- stateSecretConfigured: stateSigner?.stable ?? false,
45
- sandboxEnabled: fleet?.enabled ?? false,
46
- sandboxProvider: fleet?.provider ?? "none",
47
- missingGithubAppEnvVars: github ? [] : [...GITHUB_APP_ENV_VARS]
48
- };
49
- }
50
-
51
- // src/integrations/github/pat.ts
52
- var PAT_SETTINGS_USER_ID = "__github_org_settings__";
53
- var FIELD_FOR_KIND = {
54
- default: "pat",
55
- reviewer: "reviewerPat"
56
- };
57
- function asToken(value) {
58
- return typeof value === "string" && value.length > 0 ? value : null;
59
- }
60
- async function getGithubPat(getStorage, orgId, kind = "default") {
61
- try {
62
- const settings = await getStorage().settings.get(orgId, PAT_SETTINGS_USER_ID);
63
- if (!settings) return null;
64
- if (kind === "reviewer") return asToken(settings.reviewerPat) ?? asToken(settings.pat);
65
- return asToken(settings.pat);
66
- } catch {
67
- return null;
68
- }
69
- }
70
- async function getGithubPatStatus(getStorage, orgId) {
71
- try {
72
- const settings = await getStorage().settings.get(orgId, PAT_SETTINGS_USER_ID);
73
- return {
74
- configured: asToken(settings?.pat) !== null,
75
- reviewerConfigured: asToken(settings?.reviewerPat) !== null
76
- };
77
- } catch {
78
- return { configured: false, reviewerConfigured: false };
79
- }
80
- }
81
- async function setGithubPat(storage, orgId, pat, kind = "default") {
82
- const existing = await storage.settings.get(orgId, PAT_SETTINGS_USER_ID) ?? {};
83
- await storage.settings.save(orgId, PAT_SETTINGS_USER_ID, { ...existing, [FIELD_FOR_KIND[kind]]: pat });
84
- }
85
- async function clearGithubPat(storage, orgId, kind = "default") {
86
- const existing = await storage.settings.get(orgId, PAT_SETTINGS_USER_ID);
87
- const field = FIELD_FOR_KIND[kind];
88
- if (!existing?.[field]) return;
89
- const { [field]: _removed, ...rest } = existing;
90
- await storage.settings.save(orgId, PAT_SETTINGS_USER_ID, rest);
91
- }
92
-
93
- // src/integrations/github/sandbox.ts
94
- import { createHash } from "crypto";
95
- function bindingStore(row, storage) {
96
- return {
97
- sandboxId: row.sandboxId,
98
- setSandboxId: (id) => id === null ? storage.clearBinding({ id: row.id }) : storage.setSandboxId({ id: row.id, sandboxId: id }),
99
- clear: () => storage.clearBinding({ id: row.id })
100
- };
101
- }
102
- async function ensureProjectSandbox(options) {
103
- const { fleet, row, storage, token, onProgress } = options;
104
- return fleet.ensureSandbox(bindingStore(row, storage), { GH_TOKEN: token }, onProgress);
105
- }
106
- async function teardownProjectSandbox(options) {
107
- const { fleet, row, storage, sandbox } = options;
108
- return fleet.teardownSandbox(bindingStore(row, storage), sandbox);
109
- }
110
- function shellQuote(value) {
111
- return `'` + value.split(`'`).join(`'\\''`) + `'`;
112
- }
113
- async function sh(sandbox, script) {
114
- return sandbox.executeCommand("sh", ["-c", script]);
115
- }
116
- var MaterializeError = class extends Error {
117
- constructor(message, code) {
118
- super(message);
119
- this.code = code;
120
- this.name = "MaterializeError";
121
- }
122
- code;
123
- };
124
- function tokenUrl(repoFullName, token) {
125
- return `https://x-access-token:${token}@github.com/${repoFullName}.git`;
126
- }
127
- function cleanUrl(repoFullName) {
128
- return `https://github.com/${repoFullName}.git`;
129
- }
130
- async function materializeRepo(options) {
131
- const { row: sandboxRow, repoInfo, sandbox, token, storage, onProgress } = options;
132
- const workdir = sandboxRow.sandboxWorkdir;
133
- const repo = repoInfo.repoFullName;
134
- if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
135
- throw new MaterializeError(`Refusing to materialize: invalid repo full name '${repo}'.`, "clone-failed");
136
- }
137
- if (!/^[A-Za-z0-9_./-]+$/.test(repoInfo.defaultBranch)) {
138
- throw new MaterializeError(
139
- `Refusing to materialize: invalid default branch '${repoInfo.defaultBranch}'.`,
140
- "clone-failed"
141
- );
142
- }
143
- const gitVersion = await sh(sandbox, "git --version");
144
- if (gitVersion.exitCode !== 0) {
145
- throw new MaterializeError(
146
- "git is not installed in the sandbox. The sandbox template must include git.",
147
- "git-missing"
148
- );
149
- }
150
- const authUrl = tokenUrl(repo, token);
151
- const alreadyMaterialized = Boolean(sandboxRow.materializedAt) || await hasExistingCheckout(sandbox, workdir, repo);
152
- try {
153
- if (!alreadyMaterialized) {
154
- reportProgress(onProgress, {
155
- phase: "cloning",
156
- message: `Cloning ${repo} (first open can take a minute)\u2026`
157
- });
158
- const clone = await sh(
159
- sandbox,
160
- `git clone --depth=1 --single-branch --branch ${shellQuote(repoInfo.defaultBranch)} ${shellQuote(authUrl)} ${shellQuote(workdir)}`
161
- );
162
- if (clone.exitCode !== 0) {
163
- throw classifyGitFailure(clone, "clone-failed");
164
- }
165
- } else {
166
- reportProgress(onProgress, { phase: "pulling", message: `Updating ${repo} to the latest changes\u2026` });
167
- const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(authUrl)}`);
168
- if (setUrl.exitCode !== 0) {
169
- throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr}`, "pull-failed");
170
- }
171
- const pull = await sh(sandbox, `git -C ${shellQuote(workdir)} pull --ff-only`);
172
- if (pull.exitCode !== 0) {
173
- throw classifyGitFailure(pull, "pull-failed");
174
- }
175
- }
176
- } finally {
177
- await scrubRemote(sandbox, workdir, repo, alreadyMaterialized);
178
- }
179
- reportProgress(onProgress, { phase: "finalizing", message: "Finalizing workspace\u2026" });
180
- await storage.markMaterialized({ id: sandboxRow.id });
181
- }
182
- async function hasExistingCheckout(sandbox, workdir, repoFullName) {
183
- const result = await sh(sandbox, `git -C ${shellQuote(workdir)} remote get-url origin`);
184
- if (result.exitCode !== 0) return false;
185
- const url = result.stdout.trim().toLowerCase();
186
- const suffix = `github.com/${repoFullName.toLowerCase()}`;
187
- return url.endsWith(`${suffix}.git`) || url.endsWith(suffix);
188
- }
189
- async function scrubRemote(sandbox, workdir, repoFullName, expectGitDir) {
190
- const result = await sh(
191
- sandbox,
192
- `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(cleanUrl(repoFullName))}`
193
- );
194
- if (result.exitCode !== 0 && expectGitDir) {
195
- throw new MaterializeError(
196
- `Failed to scrub installation token from git remote: ${result.stderr.trim() || result.stdout.trim()}`,
197
- "pull-failed"
198
- );
199
- }
200
- }
201
- function classifyGitFailure(result, fallback) {
202
- const stderr = result.stderr || "";
203
- if (/could not resolve host|failed to connect|network is unreachable|Connection timed out/i.test(stderr)) {
204
- return new MaterializeError(
205
- "The sandbox could not reach github.com. The sandbox network must allow outbound egress to github.com.",
206
- "egress-blocked"
207
- );
208
- }
209
- const verb = fallback === "clone-failed" ? "clone" : fallback === "pull-failed" ? "pull" : "push";
210
- return new MaterializeError(`git ${verb} failed: ${stderr}`, fallback);
211
- }
212
- function isValidGitRef(value) {
213
- return typeof value === "string" && value.length > 0 && value.length <= 255 && // Reject leading-dash refs (e.g. `--mirror`) so the value can never be
214
- // parsed as a git option when interpolated into a command.
215
- !value.startsWith("-") && /^[A-Za-z0-9_./-]+$/.test(value);
216
- }
217
- function resolveGitIdentity(identity) {
218
- const login = (identity.login || "").trim();
219
- const name = (identity.name || "").trim() || login || "Mastra Code";
220
- const email = (identity.email || "").trim() || (login ? `${login}@users.noreply.github.com` : "mastra-code@users.noreply.github.com");
221
- return { name, email };
222
- }
223
- async function configureGitIdentity(sandbox, workdir, identity) {
224
- const { name, email } = resolveGitIdentity(identity);
225
- const setName = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.name ${shellQuote(name)}`);
226
- if (setName.exitCode !== 0) {
227
- throw new MaterializeError(`Failed to set git user.name: ${setName.stderr.trim()}`, "commit-failed");
228
- }
229
- const setEmail = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.email ${shellQuote(email)}`);
230
- if (setEmail.exitCode !== 0) {
231
- throw new MaterializeError(`Failed to set git user.email: ${setEmail.stderr.trim()}`, "commit-failed");
232
- }
233
- }
234
- async function withInstallToken(sandbox, workdir, repoFullName, token, fn) {
235
- if (!/^[\w.-]+\/[\w.-]+$/.test(repoFullName)) {
236
- throw new MaterializeError(`Refusing to push: invalid repo full name '${repoFullName}'.`, "push-failed");
237
- }
238
- const setUrl = await sh(
239
- sandbox,
240
- `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(tokenUrl(repoFullName, token))}`
241
- );
242
- if (setUrl.exitCode !== 0) {
243
- await scrubRemote(sandbox, workdir, repoFullName, false);
244
- throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr.trim()}`, "push-failed");
245
- }
246
- try {
247
- return await fn();
248
- } finally {
249
- await scrubRemote(sandbox, workdir, repoFullName, true);
250
- }
251
- }
252
- async function pushBranch(sandbox, workdir, branch, token, repoFullName) {
253
- if (!isValidGitRef(branch)) {
254
- throw new MaterializeError(`Refusing to push: invalid branch name '${branch}'.`, "push-failed");
255
- }
256
- await withInstallToken(sandbox, workdir, repoFullName, token, async () => {
257
- const push = await sh(sandbox, `git -C ${shellQuote(workdir)} push -u origin ${shellQuote(branch)}`);
258
- if (push.exitCode !== 0) {
259
- throw classifyGitFailure(push, "push-failed");
260
- }
261
- });
262
- }
263
- async function commitAll(sandbox, workdir, message, identity) {
264
- await configureGitIdentity(sandbox, workdir, identity);
265
- const add = await sh(sandbox, `git -C ${shellQuote(workdir)} add -A`);
266
- if (add.exitCode !== 0) {
267
- throw new MaterializeError(`git add failed: ${add.stderr.trim() || add.stdout.trim()}`, "commit-failed");
268
- }
269
- const staged = await sh(sandbox, `git -C ${shellQuote(workdir)} diff --cached --quiet`);
270
- if (staged.exitCode === 0) {
271
- return { committed: false };
272
- }
273
- const commit = await sh(sandbox, `git -C ${shellQuote(workdir)} commit -m ${shellQuote(message)}`);
274
- if (commit.exitCode !== 0) {
275
- throw new MaterializeError(`git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}`, "commit-failed");
276
- }
277
- return { committed: true };
278
- }
279
- var WorktreeError = class extends Error {
280
- constructor(message, code) {
281
- super(message);
282
- this.code = code;
283
- this.name = "WorktreeError";
284
- }
285
- code;
286
- };
287
- function safeBranchDir(branch) {
288
- const sanitized = branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/\/+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 100) || "work";
289
- if (sanitized === branch) return sanitized;
290
- const hash = createHash("sha256").update(branch).digest("hex").slice(0, 8);
291
- return `${sanitized}-${hash}`;
292
- }
293
- function computeWorktreePath(repoWorkdir, branch) {
294
- const parent = repoWorkdir.replace(/\/+$/, "").split("/").slice(0, -1).join("/") || "";
295
- return `${parent}/worktrees/${safeBranchDir(branch)}`;
296
- }
297
-
298
- // src/integrations/github/subscriptions.ts
299
- function changeRequestTargetKey(input) {
300
- return `change-request:${input.installationExternalId}:${input.repositoryExternalId}:${input.changeRequestId}`;
301
- }
302
- function sameSession(row, input) {
303
- return row.orgId === input.orgId && row.sessionId === input.sessionId && row.resourceId === input.resourceId && row.threadId === input.threadId && (row.sessionScope ?? "") === (input.sessionScope ?? "");
304
- }
305
- async function subscribeToPullRequest(input, storage) {
306
- const targetKey = changeRequestTargetKey(input);
307
- const existing = (await storage.subscriptions.listByTarget(targetKey)).find((row) => sameSession(row, input));
308
- if (existing) {
309
- if (existing.status !== "open") await storage.subscriptions.updateStatus(existing.id, "open");
310
- return { ...existing, status: "open" };
311
- }
312
- return storage.subscriptions.create({
313
- orgId: input.orgId,
314
- targetKey,
315
- sessionId: input.sessionId,
316
- resourceId: input.resourceId,
317
- threadId: input.threadId,
318
- sessionScope: input.sessionScope ?? "",
319
- status: "open",
320
- data: {
321
- installationExternalId: input.installationExternalId,
322
- projectRepositoryId: input.projectRepositoryId,
323
- repositoryExternalId: input.repositoryExternalId,
324
- repositorySlug: input.repositorySlug,
325
- changeRequestId: input.changeRequestId,
326
- ownerId: input.ownerId,
327
- source: input.source,
328
- subscribedByUserId: input.subscribedByUserId ?? null
329
- }
330
- });
331
- }
332
- async function listPullRequestSubscriptionsForThread(input, storage) {
333
- const rows = await storage.subscriptions.listByThread(input.resourceId, input.threadId);
334
- return rows.filter(
335
- (row) => row.orgId === input.orgId && row.resourceId === input.resourceId && row.threadId === input.threadId && (row.sessionScope ?? "") === (input.sessionScope ?? "")
336
- );
337
- }
338
- async function listPullRequestSubscriptionsForWebhook(input, options, storage) {
339
- const rows = await storage.subscriptions.listByTarget(changeRequestTargetKey(input));
340
- return options?.includeTerminal ? rows : rows.filter((row) => row.status === "open");
341
- }
342
- function retirePullRequestSubscription(id, status, storage) {
343
- return storage.subscriptions.updateStatus(id, status);
344
- }
345
-
346
- // src/integrations/github/webhook.ts
347
- import { createHmac, timingSafeEqual } from "crypto";
348
- var SUPPORTED_GITHUB_WEBHOOK_EVENTS = /* @__PURE__ */ new Set([
349
- "issues",
350
- "issue_comment",
351
- "pull_request",
352
- "pull_request_review",
353
- "pull_request_review_comment"
354
- ]);
355
- function normalizeHeader(value) {
356
- if (!value) return null;
357
- const trimmed = value.trim();
358
- return trimmed.length > 0 ? trimmed : null;
359
- }
360
- function verifySignature(rawBody, signature, secret) {
361
- if (!signature.startsWith("sha256=")) return false;
362
- const signatureHex = signature.slice("sha256=".length);
363
- if (!/^[a-fA-F0-9]{64}$/.test(signatureHex)) return false;
364
- const expectedHex = createHmac("sha256", secret).update(rawBody).digest("hex");
365
- const received = Buffer.from(signatureHex, "hex");
366
- const expected = Buffer.from(expectedHex, "hex");
367
- return received.length === expected.length && timingSafeEqual(received, expected);
368
- }
369
- async function parseGithubWebhook(c, secret) {
370
- if (!secret) {
371
- return { status: 401, body: { error: "unauthorized", message: "GitHub webhook secret is not configured" } };
372
- }
373
- const event = normalizeHeader(c.req.header("x-github-event"));
374
- const deliveryId = normalizeHeader(c.req.header("x-github-delivery"));
375
- const signature = normalizeHeader(c.req.header("x-hub-signature-256"));
376
- if (!event) return { status: 400, body: { error: "bad_request", message: "Missing x-github-event header" } };
377
- if (!deliveryId) return { status: 400, body: { error: "bad_request", message: "Missing x-github-delivery header" } };
378
- if (!signature)
379
- return { status: 401, body: { error: "unauthorized", message: "Missing x-hub-signature-256 header" } };
380
- const rawBody = await c.req.text();
381
- if (!verifySignature(rawBody, signature, secret)) {
382
- return { status: 401, body: { error: "unauthorized", message: "Invalid GitHub webhook signature" } };
383
- }
384
- let payload;
385
- try {
386
- payload = JSON.parse(rawBody);
387
- } catch {
388
- return { status: 400, body: { error: "bad_request", message: "Malformed JSON payload" } };
389
- }
390
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
391
- return { status: 400, body: { error: "bad_request", message: "Payload must be a JSON object" } };
392
- }
393
- return { event, deliveryId, payload };
394
- }
395
- function getObject(value) {
396
- return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
397
- }
398
- function getString(value) {
399
- return typeof value === "string" && value.length > 0 ? value : void 0;
400
- }
401
- function getNumber(value) {
402
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
403
- }
404
- function getBoolean(value) {
405
- return typeof value === "boolean" ? value : void 0;
406
- }
407
- function getLabels(value) {
408
- if (!Array.isArray(value)) return [];
409
- return value.map((label) => typeof label === "string" ? label : getString(getObject(label)?.name)).filter((label) => Boolean(label));
410
- }
411
- function getIssueTriageRunInput(parsed) {
412
- if (parsed.event !== "issues" || getString(parsed.payload.action) !== "opened") return null;
413
- const repository = getString(getObject(parsed.payload.repository)?.full_name);
414
- const issue = getObject(parsed.payload.issue);
415
- const sender = getString(getObject(parsed.payload.sender)?.login);
416
- const installationId = getNumber(getObject(parsed.payload.installation)?.id);
417
- const issueNumber = getNumber(issue?.number);
418
- const issueTitle = getString(issue?.title);
419
- const issueUrl = getString(issue?.html_url);
420
- if (!repository || !installationId || !issueNumber || !issueTitle || !issueUrl) return null;
421
- return {
422
- repository,
423
- issueNumber,
424
- issueTitle,
425
- issueUrl,
426
- labels: getLabels(issue?.labels),
427
- sender,
428
- installationId
429
- };
430
- }
431
- function normalizeGithubWebhookMetadata(parsed) {
432
- const { event, deliveryId, payload } = parsed;
433
- const repository = getObject(payload.repository);
434
- const issue = getObject(payload.issue);
435
- const pullRequest = getObject(payload.pull_request);
436
- const sender = getObject(payload.sender);
437
- const installation = getObject(payload.installation);
438
- const issuePullRequest = getObject(issue?.pull_request);
439
- return {
440
- event,
441
- action: getString(payload.action),
442
- deliveryId,
443
- repository: getString(repository?.full_name),
444
- repositoryId: getNumber(repository?.id),
445
- issueNumber: getNumber(issue?.number),
446
- pullRequestNumber: getNumber(pullRequest?.number) ?? (event === "issue_comment" && issuePullRequest ? getNumber(issue?.number) : void 0),
447
- sender: getString(sender?.login),
448
- senderType: getString(sender?.type),
449
- installationId: getNumber(installation?.id)
450
- };
451
- }
452
- function notificationSummary(metadata, label) {
453
- const actor = metadata.sender ? `${metadata.sender} ` : "";
454
- return `${actor}${label} on ${metadata.repository}#${metadata.pullRequestNumber}`;
455
- }
456
- function notificationTargetUrl(event, payload) {
457
- if (event === "issue_comment" || event === "pull_request_review_comment") {
458
- return getString(getObject(payload.comment)?.html_url);
459
- }
460
- if (event === "pull_request_review") {
461
- return getString(getObject(payload.review)?.html_url);
462
- }
463
- return getString(getObject(payload.pull_request)?.html_url);
464
- }
465
- function classifyGithubWebhook(parsed) {
466
- const metadata = normalizeGithubWebhookMetadata(parsed);
467
- const { event, payload } = parsed;
468
- const action = metadata.action;
469
- if (!action || !metadata.repositoryId || !metadata.installationId || !metadata.pullRequestNumber || !metadata.repository) {
470
- return void 0;
471
- }
472
- let priority;
473
- let kind;
474
- let label;
475
- let terminal = false;
476
- if (event === "pull_request_review" && action === "submitted") {
477
- const state = getString(getObject(payload.review)?.state)?.toLowerCase().replaceAll("_", "-");
478
- priority = state === "approved" || state === "changes-requested" ? "urgent" : "high";
479
- kind = state === "approved" ? "review-approved" : state === "changes-requested" ? "review-changes-requested" : "review-submitted";
480
- label = state === "approved" ? "approved the pull request" : state === "changes-requested" ? "requested changes" : "submitted a review";
481
- } else if (event === "pull_request" && action === "closed") {
482
- const merged = getBoolean(getObject(payload.pull_request)?.merged) === true;
483
- priority = "urgent";
484
- kind = merged ? "pull-request-merged" : "pull-request-closed";
485
- label = merged ? "merged the pull request" : "closed the pull request";
486
- terminal = true;
487
- } else if (event === "issue_comment" && action === "created") {
488
- priority = "high";
489
- kind = "issue-comment-created";
490
- label = "commented";
491
- } else if (event === "pull_request_review_comment" && action === "created") {
492
- priority = "high";
493
- kind = "review-comment-created";
494
- label = "left a review comment";
495
- } else if (event === "pull_request" && action === "reopened") {
496
- priority = "high";
497
- kind = "pull-request-reopened";
498
- label = "reopened the pull request";
499
- } else if (event === "pull_request_review" && action === "dismissed") {
500
- priority = "high";
501
- kind = "review-dismissed";
502
- label = "dismissed a review";
503
- } else if (event === "pull_request" && [
504
- "synchronize",
505
- "ready_for_review",
506
- "converted_to_draft",
507
- "assigned",
508
- "unassigned",
509
- "review_requested",
510
- "review_request_removed"
511
- ].includes(action)) {
512
- priority = "medium";
513
- kind = `pull-request-${action.replaceAll("_", "-")}`;
514
- label = action.replaceAll("_", " ");
515
- } else if (event === "pull_request" && ["edited", "labeled", "unlabeled", "milestoned", "demilestoned"].includes(action)) {
516
- priority = "low";
517
- kind = `pull-request-${action.replaceAll("_", "-")}`;
518
- label = action.replaceAll("_", " ");
519
- } else {
520
- return void 0;
521
- }
522
- return {
523
- action,
524
- kind,
525
- priority,
526
- summary: notificationSummary(metadata, label),
527
- terminal,
528
- metadata: {
529
- ...metadata,
530
- pullRequestNumber: metadata.pullRequestNumber,
531
- repositoryId: metadata.repositoryId,
532
- installationId: metadata.installationId
533
- },
534
- payload
535
- };
536
- }
537
- async function resolveSubscriptionSession(controller, subscription) {
538
- const { sessionId, resourceId, threadId } = subscription;
539
- if (!sessionId || !resourceId || !threadId) {
540
- throw new Error(`GitHub subscription ${subscription.id} is missing its session binding.`);
541
- }
542
- const scope = subscription.sessionScope || void 0;
543
- let session = await controller.getSessionByResource(resourceId, scope);
544
- if (!session) {
545
- const tags = {
546
- factoryProjectId: resourceId,
547
- projectRepositoryId: subscription.data.projectRepositoryId,
548
- ...scope ? { worktreePath: scope } : {}
549
- };
550
- session = await controller.createSession({
551
- id: sessionId,
552
- ownerId: subscription.data.ownerId,
553
- resourceId,
554
- scope,
555
- tags
556
- });
557
- }
558
- if (session.thread.getId() !== threadId) {
559
- await session.thread.switch({ threadId, emitEvent: false });
560
- }
561
- if (session.thread.getId() !== threadId) {
562
- throw new Error(`Session ${sessionId} did not bind thread ${threadId}.`);
563
- }
564
- return session;
565
- }
566
- var AUTHORIZED_BOTS = /* @__PURE__ */ new Set(["coderabbitai[bot]", "devin-ai-integration[bot]"]);
567
- var AUTHORIZED_PERMISSIONS = /* @__PURE__ */ new Set(["admin", "maintain", "write"]);
568
- var PERMISSION_CHECK_TIMEOUT_MS = 5e3;
569
- var AUTHOR_GATED_KINDS = /* @__PURE__ */ new Set([
570
- "issue-comment-created",
571
- "review-comment-created",
572
- "review-submitted",
573
- "review-approved",
574
- "review-changes-requested",
575
- "review-dismissed"
576
- ]);
577
- async function isAuthorizedGithubSender(notification, github) {
578
- if (!AUTHOR_GATED_KINDS.has(notification.kind)) return true;
579
- const sender = notification.metadata.sender;
580
- const repository = notification.metadata.repository;
581
- if (!sender || !repository) return false;
582
- const normalizedSender = sender.toLowerCase();
583
- if (notification.metadata.senderType?.toLowerCase() === "bot" || normalizedSender.endsWith("[bot]")) {
584
- return AUTHORIZED_BOTS.has(normalizedSender);
585
- }
586
- if (!github) return false;
587
- const abortController = new AbortController();
588
- let timeout;
589
- try {
590
- const permission = await Promise.race([
591
- github.getRepositoryCollaboratorPermission(
592
- notification.metadata.installationId,
593
- repository,
594
- sender,
595
- abortController.signal
596
- ),
597
- new Promise((resolve) => {
598
- timeout = setTimeout(() => {
599
- abortController.abort();
600
- resolve(void 0);
601
- }, PERMISSION_CHECK_TIMEOUT_MS);
602
- })
603
- ]);
604
- return permission !== void 0 && AUTHORIZED_PERMISSIONS.has(permission);
605
- } catch {
606
- return false;
607
- } finally {
608
- if (timeout) clearTimeout(timeout);
609
- }
610
- }
611
- async function dispatchGithubWebhook(parsed, dependencies) {
612
- const notification = classifyGithubWebhook(parsed);
613
- if (!notification) return { delivered: 0, failed: 0, ignored: true };
614
- const isAuthorizedSender = dependencies.isAuthorizedSender ?? ((n) => isAuthorizedGithubSender(n, dependencies.github));
615
- if (!await isAuthorizedSender(notification)) {
616
- return { delivered: 0, failed: 0, ignored: true };
617
- }
618
- const target = {
619
- installationExternalId: notification.metadata.installationId.toString(),
620
- repositoryExternalId: notification.metadata.repositoryId.toString(),
621
- changeRequestId: notification.metadata.pullRequestNumber.toString()
622
- };
623
- const listSubscriptions = dependencies.listSubscriptions ?? ((subscriptionTarget, options) => {
624
- if (!dependencies.github) throw new Error("GitHub integration is required to load webhook subscriptions.");
625
- return listPullRequestSubscriptionsForWebhook(
626
- subscriptionTarget,
627
- options,
628
- dependencies.github.integrationStorage
629
- );
630
- });
631
- const retireSubscription = dependencies.retireSubscription ?? ((id, status) => {
632
- if (!dependencies.github) throw new Error("GitHub integration is required to retire webhook subscriptions.");
633
- return retirePullRequestSubscription(id, status, dependencies.github.integrationStorage);
634
- });
635
- const subscriptions = await listSubscriptions(target, { includeTerminal: notification.action === "reopened" });
636
- let delivered = 0;
637
- let failed = 0;
638
- for (const subscription of subscriptions) {
639
- try {
640
- const session = await resolveSubscriptionSession(dependencies.controller, subscription);
641
- const result = await session.sendNotificationSignal({
642
- source: "github",
643
- kind: notification.kind,
644
- summary: notification.summary,
645
- priority: notification.priority,
646
- payload: notification.payload,
647
- sourceId: parsed.deliveryId,
648
- dedupeKey: `${parsed.deliveryId}:${subscription.sessionId}:${subscription.threadId}`,
649
- coalesceKey: `github:${subscription.data.repositoryExternalId}:pull-request:${subscription.data.changeRequestId}`,
650
- metadata: {
651
- event: notification.metadata.event,
652
- action: notification.action,
653
- repository: notification.metadata.repository,
654
- issueNumber: notification.metadata.issueNumber,
655
- pullRequestNumber: notification.metadata.pullRequestNumber,
656
- targetUrl: notificationTargetUrl(parsed.event, parsed.payload),
657
- deliveryId: parsed.deliveryId
658
- }
659
- });
660
- await Promise.all([result.persisted, result.accepted].filter(Boolean));
661
- if (notification.terminal) {
662
- await retireSubscription(subscription.id, notification.kind === "pull-request-merged" ? "merged" : "closed");
663
- } else if (notification.action === "reopened") {
664
- await retireSubscription(subscription.id, "open");
665
- }
666
- delivered += 1;
667
- } catch (error) {
668
- failed += 1;
669
- dependencies.onTargetError?.(subscription, error);
670
- }
671
- }
672
- return { delivered, failed, ignored: false };
673
- }
674
- async function handleGithubWebhook(c, options) {
675
- const parsed = await parseGithubWebhook(c, options.github.webhookSecret);
676
- if ("status" in parsed) return parsed;
677
- if (!SUPPORTED_GITHUB_WEBHOOK_EVENTS.has(parsed.event)) {
678
- return { status: 202, body: { ok: true, ignored: true } };
679
- }
680
- const metadata = normalizeGithubWebhookMetadata(parsed);
681
- console.info("[GitHub Webhook]", metadata);
682
- if (options.ingestFactoryEvent) {
683
- await options.ingestFactoryEvent(parsed);
684
- } else {
685
- const issueTriageRun = getIssueTriageRunInput(parsed);
686
- if (issueTriageRun && options.runIssueTriage) {
687
- void options.runIssueTriage(issueTriageRun).catch((error) => {
688
- console.error("[GitHub Webhook] Failed to run issue triage", {
689
- deliveryId: metadata.deliveryId,
690
- repository: metadata.repository,
691
- issueNumber: metadata.issueNumber,
692
- error: error instanceof Error ? error.message : String(error)
693
- });
694
- });
695
- }
696
- }
697
- if (!options.controller) {
698
- return { status: 202, body: { ok: true } };
699
- }
700
- const result = await dispatchGithubWebhook(parsed, options);
701
- if (result.failed > 0) {
702
- console.warn(`[GitHub Webhook] ${result.failed} subscribed target(s) failed for delivery ${parsed.deliveryId}.`);
703
- }
704
- return { status: 202, body: { ok: true, ...result.ignored ? { ignored: true } : {} } };
705
- }
706
-
707
- // src/integrations/github/routes.ts
708
- var sessionOperationLocks = /* @__PURE__ */ new Map();
10
+ //#region src/integrations/github/routes.ts
11
+ /**
12
+ * Mastra `apiRoutes` for the GitHub App project feature.
13
+ *
14
+ * Registered alongside the other `/web/*` routes, behind the host auth gate.
15
+ * Every route additionally re-checks the authenticated user via the injected
16
+ * `RouteAuth` seam and scopes all rows by that user's stable id, so a user can
17
+ * only ever see and operate on their own installations and projects.
18
+ *
19
+ * When the feature is disabled (`isGithubFeatureEnabled()` false), `buildGithubRoutes`
20
+ * returns only `GET /web/github/status`, which reports `enabled:false`
21
+ * so the SPA can cleanly hide all GitHub UI.
22
+ */
23
+ const sessionOperationLocks = /* @__PURE__ */ new Map();
24
+ /**
25
+ * Serialize same-session mutations within one Factory process. Factory sessions
26
+ * normally issue these operations sequentially, so this lock is probably not
27
+ * necessary; keep the cheap local guard until that invariant is enforced by
28
+ * the request protocol. It intentionally does not consume a database connection.
29
+ */
709
30
  function withSessionOperationLock(sessionId, fn) {
710
- const previous = sessionOperationLocks.get(sessionId) ?? Promise.resolve();
711
- const next = previous.then(fn, fn);
712
- const tail = next.then(
713
- () => void 0,
714
- () => void 0
715
- );
716
- sessionOperationLocks.set(sessionId, tail);
717
- void tail.then(() => {
718
- if (sessionOperationLocks.get(sessionId) === tail) sessionOperationLocks.delete(sessionId);
719
- });
720
- return next;
721
- }
31
+ const next = (sessionOperationLocks.get(sessionId) ?? Promise.resolve()).then(fn, fn);
32
+ const tail = next.then(() => void 0, () => void 0);
33
+ sessionOperationLocks.set(sessionId, tail);
34
+ tail.then(() => {
35
+ if (sessionOperationLocks.get(sessionId) === tail) sessionOperationLocks.delete(sessionId);
36
+ });
37
+ return next;
38
+ }
39
+ /** Erase a route handler's path-parameterized context to a plain `Context`. */
722
40
  function loose(c) {
723
- return c;
41
+ return c;
724
42
  }
43
+ /**
44
+ * Resolve the Factory project's default model for a triage run. Best-effort:
45
+ * a missing project or an uninitialized storage domain simply means "no
46
+ * default", never a failed run.
47
+ */
725
48
  async function resolveFactoryDefaultModelId(projects, factoryProjectId) {
726
- if (!projects || !factoryProjectId) return void 0;
727
- try {
728
- const project = await projects.getById({ id: factoryProjectId });
729
- return project?.defaultModelId ?? void 0;
730
- } catch {
731
- return void 0;
732
- }
49
+ if (!projects || !factoryProjectId) return void 0;
50
+ try {
51
+ return (await projects.getById({ id: factoryProjectId }))?.defaultModelId ?? void 0;
52
+ } catch {
53
+ return;
54
+ }
733
55
  }
734
56
  function pullRequestNumberFromUrl(value, expectedRepo) {
735
- try {
736
- const url = new URL(value);
737
- const match = url.pathname.match(/^\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/);
738
- if (url.protocol !== "https:" || url.hostname !== "github.com" || match?.[1]?.toLowerCase() !== expectedRepo.toLowerCase()) {
739
- return void 0;
740
- }
741
- const number = Number(match[2]);
742
- return Number.isInteger(number) && number > 0 ? number : void 0;
743
- } catch {
744
- return void 0;
745
- }
57
+ try {
58
+ const url = new URL(value);
59
+ const match = url.pathname.match(/^\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/);
60
+ if (url.protocol !== "https:" || url.hostname !== "github.com" || match?.[1]?.toLowerCase() !== expectedRepo.toLowerCase()) return;
61
+ const number = Number(match[2]);
62
+ return Number.isInteger(number) && number > 0 ? number : void 0;
63
+ } catch {
64
+ return;
65
+ }
746
66
  }
747
67
  function isCanonicalGithubIssueUrl(value, repoFullName, issueNumber) {
748
- try {
749
- const url = new URL(value);
750
- const [owner, repo] = repoFullName.split("/");
751
- return url.protocol === "https:" && url.hostname === "github.com" && url.pathname === `/${owner}/${repo}/issues/${issueNumber}` && url.search === "" && url.hash === "";
752
- } catch {
753
- return false;
754
- }
755
- }
756
- function isValidGitRef2(value) {
757
- return typeof value === "string" && value.length > 0 && value.length <= 255 && /^[A-Za-z0-9_./-]+$/.test(value);
758
- }
68
+ try {
69
+ const url = new URL(value);
70
+ const [owner, repo] = repoFullName.split("/");
71
+ return url.protocol === "https:" && url.hostname === "github.com" && url.pathname === `/${owner}/${repo}/issues/${issueNumber}` && url.search === "" && url.hash === "";
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+ /**
77
+ * Validate a git branch/ref name against a strict whitelist. The value is later
78
+ * interpolated into a shell `git clone --branch` command, so it must never
79
+ * contain shell metacharacters. We accept only git-ref-safe characters and
80
+ * reject anything else rather than relying on shell quoting alone.
81
+ */
82
+ function isValidGitRef(value) {
83
+ return typeof value === "string" && value.length > 0 && value.length <= 255 && /^[A-Za-z0-9_./-]+$/.test(value);
84
+ }
85
+ /**
86
+ * Resolve the org-scoped tenant for a GitHub request. GitHub project features
87
+ * are org-owned, so they require both a signed-in user and a WorkOS
88
+ * organization. Returns the `(orgId, userId)` tenant (with `orgId` narrowed to a
89
+ * non-null string) or a ready-to-return error response: 401 when unauthenticated,
90
+ * 403 when the user has no organization (personal account).
91
+ *
92
+ * Resolves the session from the request cookie itself (via `auth.ensureUser`)
93
+ * instead of relying on the auth gate's context stash: on platform deploys
94
+ * custom `apiRoutes` run on an isolated sub-app context where the gate's
95
+ * `c.set(...)` is invisible. When the gate stash IS visible (local Hono
96
+ * server), `auth.ensureUser` returns the cached user and this is a no-op.
97
+ */
759
98
  async function resolveOrgTenant(c, auth) {
760
- await auth.ensureUser(c);
761
- const tenant = auth.tenant(c);
762
- if (!tenant) return { response: c.json({ error: "unauthorized" }, 401) };
763
- if (!tenant.orgId) {
764
- return {
765
- response: c.json(
766
- {
767
- error: "organization_required",
768
- message: "GitHub projects require a WorkOS organization. Personal accounts cannot connect repositories."
769
- },
770
- 403
771
- )
772
- };
773
- }
774
- return { tenant: { orgId: tenant.orgId, userId: tenant.userId } };
775
- }
99
+ await auth.ensureUser(c);
100
+ const tenant = auth.tenant(c);
101
+ if (!tenant) return { response: c.json({ error: "unauthorized" }, 401) };
102
+ if (!tenant.orgId) return { response: c.json({
103
+ error: "organization_required",
104
+ message: "GitHub projects require a WorkOS organization. Personal accounts cannot connect repositories."
105
+ }, 403) };
106
+ return { tenant: {
107
+ orgId: tenant.orgId,
108
+ userId: tenant.userId
109
+ } };
110
+ }
111
+ /**
112
+ * Parse a 1-based `page` query param. Missing means page 1; anything that is
113
+ * not a small positive integer is rejected (`null`).
114
+ */
776
115
  function parseListPage(raw) {
777
- if (raw === void 0) return 1;
778
- if (!/^\d{1,5}$/.test(raw)) return null;
779
- const page = Number(raw);
780
- return page >= 1 ? page : null;
116
+ if (raw === void 0) return 1;
117
+ if (!/^\d{1,5}$/.test(raw)) return null;
118
+ const page = Number(raw);
119
+ return page >= 1 ? page : null;
781
120
  }
782
- var VALID_ISSUE_LABEL_FILTERS = /* @__PURE__ */ new Set(["auto-triaged", "needs-approval"]);
121
+ const VALID_ISSUE_LABEL_FILTERS = /* @__PURE__ */ new Set(["auto-triaged", "needs-approval"]);
783
122
  function parseIssueLabelFilter(raw) {
784
- if (raw === void 0 || raw === "") return void 0;
785
- if (VALID_ISSUE_LABEL_FILTERS.has(raw)) return raw;
786
- return null;
123
+ if (raw === void 0 || raw === "") return void 0;
124
+ if (VALID_ISSUE_LABEL_FILTERS.has(raw)) return raw;
125
+ return null;
787
126
  }
788
127
  function parseIssueNumberParam(raw) {
789
- if (!raw || !/^\d{1,10}$/.test(raw)) return null;
790
- const issueNumber = Number(raw);
791
- return Number.isSafeInteger(issueNumber) && issueNumber > 0 ? issueNumber : null;
128
+ if (!raw || !/^\d{1,10}$/.test(raw)) return null;
129
+ const issueNumber = Number(raw);
130
+ return Number.isSafeInteger(issueNumber) && issueNumber > 0 ? issueNumber : null;
792
131
  }
793
132
  function parseStringList(value) {
794
- if (!Array.isArray(value)) return [];
795
- return value.filter((item) => typeof item === "string" && item.length > 0);
133
+ if (!Array.isArray(value)) return [];
134
+ return value.filter((item) => typeof item === "string" && item.length > 0);
796
135
  }
797
136
  async function resolveProjectRepository(args) {
798
- const projectRepository = await args.github.sourceControlStorage.projectRepositories.get({
799
- orgId: args.orgId,
800
- id: args.projectRepositoryId
801
- });
802
- if (!projectRepository) return null;
803
- const connection = await args.github.sourceControlStorage.connections.get({
804
- orgId: args.orgId,
805
- id: projectRepository.connectionId
806
- });
807
- if (!connection) return null;
808
- const repository = await args.github.sourceControlStorage.repositories.get({
809
- orgId: args.orgId,
810
- id: projectRepository.repositoryId
811
- });
812
- if (!repository) return null;
813
- const installation = await args.github.sourceControlStorage.installations.get({
814
- orgId: args.orgId,
815
- id: connection.installationId
816
- });
817
- if (!installation) return null;
818
- return {
819
- ...projectRepository,
820
- connection,
821
- installation,
822
- repository,
823
- factoryProjectId: connection.factoryProjectId,
824
- defaultBranch: projectRepository.branch ?? repository.defaultBranch
825
- };
137
+ const projectRepository = await args.github.sourceControlStorage.projectRepositories.get({
138
+ orgId: args.orgId,
139
+ id: args.projectRepositoryId
140
+ });
141
+ if (!projectRepository) return null;
142
+ const connection = await args.github.sourceControlStorage.connections.get({
143
+ orgId: args.orgId,
144
+ id: projectRepository.connectionId
145
+ });
146
+ if (!connection) return null;
147
+ const repository = await args.github.sourceControlStorage.repositories.get({
148
+ orgId: args.orgId,
149
+ id: projectRepository.repositoryId
150
+ });
151
+ if (!repository) return null;
152
+ const installation = await args.github.sourceControlStorage.installations.get({
153
+ orgId: args.orgId,
154
+ id: connection.installationId
155
+ });
156
+ if (!installation) return null;
157
+ return {
158
+ ...projectRepository,
159
+ connection,
160
+ installation,
161
+ repository,
162
+ factoryProjectId: connection.factoryProjectId,
163
+ defaultBranch: projectRepository.branch ?? repository.defaultBranch
164
+ };
826
165
  }
827
166
  function polledIssueEvent(project, issue) {
828
- const repositoryId = Number(project.repository.externalId);
829
- return {
830
- event: "issues",
831
- deliveryId: `poll:${repositoryId}:issue:${issue.number}:${issue.createdAt}`,
832
- payload: {
833
- action: "opened",
834
- installation: { id: Number(project.installation.externalId) },
835
- repository: { id: repositoryId, full_name: project.repository.slug },
836
- sender: { login: issue.author ?? "__unknown__" },
837
- issue: {
838
- number: issue.number,
839
- title: issue.title,
840
- html_url: issue.url,
841
- created_at: issue.createdAt,
842
- labels: issue.labels.map((name) => ({ name }))
843
- }
844
- }
845
- };
167
+ const repositoryId = Number(project.repository.externalId);
168
+ return {
169
+ event: "issues",
170
+ deliveryId: `poll:${repositoryId}:issue:${issue.number}:${issue.createdAt}`,
171
+ payload: {
172
+ action: "opened",
173
+ installation: { id: Number(project.installation.externalId) },
174
+ repository: {
175
+ id: repositoryId,
176
+ full_name: project.repository.slug
177
+ },
178
+ sender: { login: issue.author ?? "__unknown__" },
179
+ issue: {
180
+ number: issue.number,
181
+ title: issue.title,
182
+ html_url: issue.url,
183
+ created_at: issue.createdAt,
184
+ labels: issue.labels.map((name) => ({ name }))
185
+ }
186
+ }
187
+ };
846
188
  }
847
189
  function polledPullRequestEvent(project, pullRequest) {
848
- const repositoryId = Number(project.repository.externalId);
849
- return {
850
- event: "pull_request",
851
- deliveryId: `poll:${repositoryId}:pull-request:${pullRequest.number}:${pullRequest.createdAt}`,
852
- payload: {
853
- action: "opened",
854
- installation: { id: Number(project.installation.externalId) },
855
- repository: { id: repositoryId, full_name: project.repository.slug },
856
- sender: { login: pullRequest.author ?? "__unknown__" },
857
- pull_request: {
858
- number: pullRequest.number,
859
- title: pullRequest.title,
860
- html_url: pullRequest.url,
861
- created_at: pullRequest.createdAt,
862
- state: "open",
863
- merged: false,
864
- head: { ref: pullRequest.headBranch },
865
- base: { ref: pullRequest.baseBranch }
866
- }
867
- }
868
- };
190
+ const repositoryId = Number(project.repository.externalId);
191
+ return {
192
+ event: "pull_request",
193
+ deliveryId: `poll:${repositoryId}:pull-request:${pullRequest.number}:${pullRequest.createdAt}`,
194
+ payload: {
195
+ action: "opened",
196
+ installation: { id: Number(project.installation.externalId) },
197
+ repository: {
198
+ id: repositoryId,
199
+ full_name: project.repository.slug
200
+ },
201
+ sender: { login: pullRequest.author ?? "__unknown__" },
202
+ pull_request: {
203
+ number: pullRequest.number,
204
+ title: pullRequest.title,
205
+ html_url: pullRequest.url,
206
+ created_at: pullRequest.createdAt,
207
+ state: "open",
208
+ merged: false,
209
+ head: { ref: pullRequest.headBranch },
210
+ base: { ref: pullRequest.baseBranch }
211
+ }
212
+ }
213
+ };
869
214
  }
870
215
  async function ingestPolledEvents(events, ingestFactoryEvent) {
871
- if (!ingestFactoryEvent) return;
872
- const results = await Promise.allSettled(events.map((event) => ingestFactoryEvent(event)));
873
- const rejected = results.find((result) => result.status === "rejected");
874
- if (rejected) throw rejected.reason;
875
- }
216
+ if (!ingestFactoryEvent) return;
217
+ const rejected = (await Promise.allSettled(events.map((event) => ingestFactoryEvent(event)))).find((result) => result.status === "rejected");
218
+ if (rejected) throw rejected.reason;
219
+ }
220
+ /**
221
+ * Build the GitHub routes as Mastra `apiRoutes`. When the feature is disabled,
222
+ * returns only the `status` route so the SPA can detect the disabled state.
223
+ */
876
224
  function buildGithubRoutes(options) {
877
- const routes = [];
878
- const { auth, fleet, storage, github, stateSigner, emitAudit } = options;
879
- const diagnostics = () => getGithubFeatureDiagnostics({ github, auth, appDbConfigured: storage !== void 0, stateSigner, fleet });
880
- routes.push(
881
- registerApiRoute("/web/github/status", {
882
- method: "GET",
883
- requiresAuth: false,
884
- handler: async (c) => {
885
- if (!isGithubFeatureEnabled({ github, auth }) || !github || !stateSigner) {
886
- return c.json({
887
- enabled: false,
888
- connected: false,
889
- installations: [],
890
- reason: "missing_config",
891
- diagnostics: diagnostics()
892
- });
893
- }
894
- await auth.ensureUser(loose(c));
895
- const tenant = auth.tenant(loose(c));
896
- if (!tenant) return c.json({ error: "unauthorized", reason: "auth_required" }, 401);
897
- if (!tenant.orgId) {
898
- return c.json({
899
- enabled: true,
900
- sandboxEnabled: fleet.enabled,
901
- organizationRequired: true,
902
- connected: false,
903
- installations: [],
904
- reason: "organization_required",
905
- diagnostics: diagnostics()
906
- });
907
- }
908
- const rows = options.github ? await options.github.sourceControlStorage.installations.list({ orgId: tenant.orgId }) : [];
909
- const connected = rows.length > 0;
910
- return c.json({
911
- enabled: true,
912
- sandboxEnabled: fleet.enabled,
913
- connected,
914
- installations: rows.map((r) => ({
915
- installationId: Number(r.externalId),
916
- accountLogin: r.accountName,
917
- accountType: r.accountType
918
- })),
919
- reason: connected ? "ready" : "not_connected",
920
- diagnostics: diagnostics()
921
- });
922
- }
923
- })
924
- );
925
- if (!isGithubFeatureEnabled({ github, auth }) || !github || !stateSigner) {
926
- return routes;
927
- }
928
- const signState = (orgId, userId) => stateSigner.sign(orgId, userId);
929
- const verifyState = (state) => stateSigner.verify(state);
930
- const { runIssueTriage } = options;
931
- const runBoardIssueTriage = runIssueTriage ? async (input) => {
932
- if (!input.resourceId || !input.projectPath) {
933
- throw new Error("GitHub issue triage requires an explicit Factory project repository");
934
- }
935
- await github.addIssueLabels(input.installationId, input.repository, input.issueNumber, ["auto-triaged"]);
936
- return runIssueTriage({
937
- ...input,
938
- defaultModelId: input.defaultModelId ?? await resolveFactoryDefaultModelId(options.projects, input.resourceId),
939
- labels: input.labels.includes("auto-triaged") ? input.labels : [...input.labels, "auto-triaged"]
940
- });
941
- } : void 0;
942
- routes.push(
943
- registerApiRoute("/web/github/subscriptions", {
944
- method: "GET",
945
- handler: async (c) => {
946
- await auth.ensureUser(loose(c));
947
- const tenant = auth.tenant(loose(c));
948
- if (!tenant?.orgId) return c.json({ error: "unauthorized" }, 401);
949
- const resourceId = c.req.query("resourceId");
950
- const threadId = c.req.query("threadId");
951
- const sessionScope = c.req.query("scope");
952
- if (!resourceId || !threadId) return c.json({ error: "resourceId and threadId are required" }, 400);
953
- const subscriptions = await listPullRequestSubscriptionsForThread(
954
- {
955
- orgId: tenant.orgId,
956
- resourceId,
957
- threadId,
958
- sessionScope
959
- },
960
- github.integrationStorage
961
- );
962
- return c.json({
963
- subscriptions: subscriptions.map((subscription) => ({
964
- id: subscription.id,
965
- repoFullName: subscription.data.repositorySlug,
966
- pullRequestNumber: Number(subscription.data.changeRequestId),
967
- status: subscription.status,
968
- url: `https://github.com/${subscription.data.repositorySlug}/pull/${subscription.data.changeRequestId}`
969
- }))
970
- });
971
- }
972
- }),
973
- registerApiRoute("/web/github/webhook", {
974
- method: "POST",
975
- requiresAuth: false,
976
- handler: async (c) => {
977
- const result = await handleGithubWebhook(loose(c), {
978
- github,
979
- runIssueTriage: runBoardIssueTriage,
980
- ingestFactoryEvent: options.ingestFactoryEvent,
981
- ...options.controller ? {
982
- controller: options.controller,
983
- onTargetError: (subscription, error) => {
984
- console.warn(
985
- `[GitHub Webhook] Delivery failed for subscription ${subscription.id} (${subscription.resourceId}/${subscription.threadId}).`,
986
- error
987
- );
988
- }
989
- } : {}
990
- });
991
- return c.json(result.body, result.status);
992
- }
993
- })
994
- );
995
- const redirectUri = options.redirectUri ?? `${(options.baseUrl ?? "").replace(/\/$/, "")}/auth/github/callback`;
996
- routes.push(
997
- registerApiRoute("/auth/github/connect", {
998
- method: "GET",
999
- requiresAuth: false,
1000
- handler: async (c) => {
1001
- const resolved = await resolveOrgTenant(loose(c), auth);
1002
- if ("response" in resolved) return resolved.response;
1003
- const state = signState(resolved.tenant.orgId, resolved.tenant.userId);
1004
- if (c.req.query("manage")) return c.redirect(github.buildInstallUrl(state));
1005
- return c.redirect(github.buildOAuthIdentifyUrl(state, redirectUri));
1006
- }
1007
- })
1008
- );
1009
- routes.push(
1010
- registerApiRoute("/auth/github/callback", {
1011
- method: "GET",
1012
- requiresAuth: false,
1013
- handler: async (c) => {
1014
- const resolved = await resolveOrgTenant(loose(c), auth);
1015
- if ("response" in resolved) return resolved.response;
1016
- const { orgId, userId } = resolved.tenant;
1017
- const state = c.req.query("state");
1018
- if (!state) {
1019
- return c.redirect(github.buildOAuthIdentifyUrl(signState(orgId, userId), redirectUri));
1020
- }
1021
- const stateTenant = verifyState(state);
1022
- if (!stateTenant || stateTenant.userId !== userId || stateTenant.orgId !== orgId) {
1023
- console.warn(
1024
- "[GitHub] Install callback rejected: state/tenant mismatch.",
1025
- JSON.stringify({
1026
- stateValid: Boolean(stateTenant),
1027
- stateOrgId: stateTenant?.orgId,
1028
- stateUserId: stateTenant?.userId,
1029
- sessionOrgId: orgId,
1030
- sessionUserId: userId
1031
- })
1032
- );
1033
- return c.redirect("/?github=error");
1034
- }
1035
- const code = c.req.query("code");
1036
- if (!code) {
1037
- return c.redirect(github.buildOAuthIdentifyUrl(signState(orgId, userId), redirectUri));
1038
- }
1039
- try {
1040
- const userToken = await github.exchangeOAuthCode(code, redirectUri);
1041
- const installations = await github.listUserInstallations(userToken);
1042
- if (installations.length === 0) {
1043
- return c.redirect(github.buildInstallUrl(signState(orgId, userId)));
1044
- }
1045
- for (const inst of installations) {
1046
- await github.sourceControlStorage.installations.upsert({
1047
- orgId,
1048
- connectedByUserId: userId,
1049
- externalId: inst.installationId.toString(),
1050
- accountName: inst.accountLogin,
1051
- accountType: inst.accountType
1052
- });
1053
- }
1054
- } catch (error) {
1055
- console.warn(
1056
- `[GitHub] Install callback failed to persist installations for org ${orgId} / user ${userId}.`,
1057
- error
1058
- );
1059
- return c.redirect("/?github=error");
1060
- }
1061
- return c.redirect("/?github=connected");
1062
- }
1063
- })
1064
- );
1065
- routes.push(
1066
- registerApiRoute("/web/github/repos", {
1067
- method: "GET",
1068
- requiresAuth: false,
1069
- handler: async (c) => {
1070
- const resolved = await resolveOrgTenant(loose(c), auth);
1071
- if ("response" in resolved) return resolved.response;
1072
- const installs = await github.sourceControlStorage.installations.list({ orgId: resolved.tenant.orgId });
1073
- const query = (c.req.query("q") ?? "").toLowerCase();
1074
- const repos = [];
1075
- const seenRepositoryIds = /* @__PURE__ */ new Set();
1076
- for (const inst of installs) {
1077
- let list;
1078
- try {
1079
- list = await github.listInstallationRepos(Number(inst.externalId));
1080
- } catch (err) {
1081
- if (err.status !== 404) throw err;
1082
- console.error(`[Mastra Factory] pruning stale GitHub installation ${inst.externalId} (404 from GitHub)`);
1083
- await github.sourceControlStorage.installations.delete({ orgId: resolved.tenant.orgId, id: inst.id });
1084
- continue;
1085
- }
1086
- for (const repo of list) {
1087
- if (query && !repo.fullName.toLowerCase().includes(query)) continue;
1088
- if (seenRepositoryIds.has(repo.id)) continue;
1089
- seenRepositoryIds.add(repo.id);
1090
- const repository = await github.sourceControlStorage.repositories.upsert({
1091
- orgId: resolved.tenant.orgId,
1092
- input: {
1093
- installationId: inst.id,
1094
- externalId: repo.id.toString(),
1095
- slug: repo.fullName,
1096
- defaultBranch: isValidGitRef2(repo.defaultBranch) ? repo.defaultBranch : "main",
1097
- providerMetadata: { private: repo.private, owner: repo.owner }
1098
- }
1099
- });
1100
- repos.push({
1101
- ...repo,
1102
- installationStorageId: inst.id,
1103
- repositoryStorageId: repository.id,
1104
- sandboxProvider: fleet.provider,
1105
- sandboxWorkdir: fleet.computeWorkdir(repo.fullName)
1106
- });
1107
- }
1108
- }
1109
- return c.json({ repos });
1110
- }
1111
- })
1112
- );
1113
- routes.push(
1114
- registerApiRoute("/web/github/projects/:id/ensure", {
1115
- method: "POST",
1116
- requiresAuth: false,
1117
- handler: async (c) => {
1118
- const resolved = await resolveOrgTenant(loose(c), auth);
1119
- if ("response" in resolved) return resolved.response;
1120
- const { orgId, userId } = resolved.tenant;
1121
- if (!fleet.enabled) {
1122
- return c.json({ error: "sandbox_not_configured", message: "No sandbox provider is configured." }, 503);
1123
- }
1124
- const projectRepositoryId = c.req.param("id");
1125
- if (!projectRepositoryId) return c.json({ error: "Project repository not found" }, 404);
1126
- const project = await resolveProjectRepository({ github, orgId, projectRepositoryId });
1127
- if (!project) {
1128
- return c.json({ error: "Project repository not found" }, 404);
1129
- }
1130
- const wantsStream = (c.req.header("accept") ?? "").includes("text/event-stream");
1131
- if (wantsStream) {
1132
- return streamSSE(loose(c), async (stream) => {
1133
- try {
1134
- const result = await prepareProject({
1135
- github,
1136
- fleet,
1137
- project,
1138
- userId,
1139
- onProgress: (ev) => void stream.writeSSE({ event: "progress", data: JSON.stringify(ev) })
1140
- });
1141
- await stream.writeSSE({ event: "done", data: JSON.stringify(result) });
1142
- } catch (err) {
1143
- await stream.writeSSE({ event: "error", data: JSON.stringify(ensureErrorPayload(err).body) });
1144
- }
1145
- });
1146
- }
1147
- try {
1148
- const result = await prepareProject({ github, fleet, project, userId });
1149
- return c.json(result);
1150
- } catch (err) {
1151
- const { status, body } = ensureErrorPayload(err);
1152
- return c.json(body, status);
1153
- }
1154
- }
1155
- })
1156
- );
1157
- routes.push(
1158
- registerApiRoute("/web/github/projects/:id/issues", {
1159
- method: "GET",
1160
- requiresAuth: false,
1161
- handler: async (c) => {
1162
- const loaded = await loadOrgProject({ github, auth, c: loose(c) });
1163
- if ("response" in loaded) return loaded.response;
1164
- const page = parseListPage(c.req.query("page"));
1165
- if (page === null) return c.json({ error: "invalid_page" }, 400);
1166
- const label = parseIssueLabelFilter(c.req.query("label"));
1167
- if (label === null) return c.json({ error: "invalid_label" }, 400);
1168
- try {
1169
- const { issues, nextCursor } = await github.intake.listIssues({
1170
- connection: {
1171
- type: "app-installation",
1172
- installationId: Number(loaded.project.installation.externalId)
1173
- },
1174
- sourceIds: [loaded.project.repository.slug],
1175
- labels: label ? [label] : void 0,
1176
- cursor: String(page)
1177
- });
1178
- const responseIssues = issues.map((issue) => ({
1179
- number: Number(issue.id),
1180
- title: issue.title,
1181
- url: issue.url,
1182
- author: issue.author,
1183
- labels: issue.labels,
1184
- comments: issue.commentCount ?? 0,
1185
- createdAt: issue.createdAt,
1186
- updatedAt: issue.updatedAt
1187
- }));
1188
- await ingestPolledEvents(
1189
- responseIssues.map((issue) => polledIssueEvent(loaded.project, issue)),
1190
- options.ingestFactoryEvent
1191
- );
1192
- return c.json({
1193
- issues: responseIssues,
1194
- nextPage: nextCursor === null ? null : Number(nextCursor)
1195
- });
1196
- } catch (err) {
1197
- return c.json(
1198
- { error: "github_fetch_failed", message: err instanceof Error ? err.message : String(err) },
1199
- 502
1200
- );
1201
- }
1202
- }
1203
- })
1204
- );
1205
- routes.push(
1206
- registerApiRoute("/web/github/projects/:id/issues/:number/triage", {
1207
- method: "POST",
1208
- requiresAuth: false,
1209
- handler: async (c) => {
1210
- const owned = await loadOwnedProject({ github, auth, fleet, c: loose(c) });
1211
- if ("response" in owned) return owned.response;
1212
- const { project, sandboxRow } = owned;
1213
- const issueNumber = parseIssueNumberParam(c.req.param("number"));
1214
- if (issueNumber === null) return c.json({ error: "invalid_issue_number" }, 400);
1215
- let body;
1216
- try {
1217
- body = await c.req.json();
1218
- } catch {
1219
- return c.json({ error: "Invalid JSON body" }, 400);
1220
- }
1221
- if (typeof body.title !== "string" || body.title.trim().length === 0 || body.title.length > 5e3) {
1222
- return c.json({ error: "invalid_title" }, 400);
1223
- }
1224
- if (typeof body.url !== "string" || body.url.trim().length === 0 || body.url.length > 2048 || !isCanonicalGithubIssueUrl(body.url, project.repository.slug, issueNumber)) {
1225
- return c.json({ error: "invalid_url" }, 400);
1226
- }
1227
- if (!runIssueTriage) return c.json({ error: "triage_unavailable" }, 503);
1228
- const branch = `factory/issue-${issueNumber}`;
1229
- const projectPath = computeWorktreePath(sandboxRow.sandboxWorkdir, branch);
1230
- await github.addIssueLabels(Number(project.installation.externalId), project.repository.slug, issueNumber, [
1231
- "auto-triaged"
1232
- ]);
1233
- const result = await runIssueTriage({
1234
- repository: project.repository.slug,
1235
- issueNumber,
1236
- issueTitle: body.title,
1237
- issueUrl: body.url,
1238
- labels: parseStringList(body.labels),
1239
- installationId: Number(project.installation.externalId),
1240
- resourceId: project.factoryProjectId,
1241
- projectPath,
1242
- branch,
1243
- defaultModelId: await resolveFactoryDefaultModelId(options.projects, project.factoryProjectId)
1244
- });
1245
- await emitAudit?.({
1246
- context: loose(c),
1247
- input: {
1248
- action: "factory.triage.started",
1249
- factoryProjectId: project.factoryProjectId,
1250
- projectRepositoryId: project.id,
1251
- targets: [{ type: "issue", id: String(issueNumber), name: body.title }],
1252
- metadata: { issueNumber, branch, threadId: result.threadId }
1253
- }
1254
- });
1255
- return c.json(
1256
- {
1257
- ok: true,
1258
- threadId: result.threadId,
1259
- projectPath: result.projectPath ?? projectPath,
1260
- branch: result.branch ?? branch
1261
- },
1262
- 202
1263
- );
1264
- }
1265
- })
1266
- );
1267
- routes.push(
1268
- registerApiRoute("/web/github/projects/:id/prs", {
1269
- method: "GET",
1270
- requiresAuth: false,
1271
- handler: async (c) => {
1272
- const loaded = await loadOrgProject({ github, auth, c: loose(c) });
1273
- if ("response" in loaded) return loaded.response;
1274
- const page = parseListPage(c.req.query("page"));
1275
- if (page === null) return c.json({ error: "invalid_page" }, 400);
1276
- try {
1277
- const { pullRequests, nextCursor } = await github.versionControl.listPullRequests({
1278
- connection: {
1279
- type: "app-installation",
1280
- installationId: Number(loaded.project.installation.externalId)
1281
- },
1282
- sourceId: loaded.project.repository.slug,
1283
- includeDrafts: false,
1284
- cursor: String(page)
1285
- });
1286
- const responsePullRequests = pullRequests.map((pr) => ({
1287
- number: Number(pr.id),
1288
- title: pr.title,
1289
- url: pr.url,
1290
- author: pr.author,
1291
- baseBranch: pr.baseBranch,
1292
- headBranch: pr.headBranch,
1293
- createdAt: pr.createdAt,
1294
- updatedAt: pr.updatedAt
1295
- }));
1296
- await ingestPolledEvents(
1297
- responsePullRequests.map((pullRequest) => polledPullRequestEvent(loaded.project, pullRequest)),
1298
- options.ingestFactoryEvent
1299
- );
1300
- return c.json({
1301
- pullRequests: responsePullRequests,
1302
- nextPage: nextCursor === null ? null : Number(nextCursor)
1303
- });
1304
- } catch (err) {
1305
- return c.json(
1306
- { error: "github_fetch_failed", message: err instanceof Error ? err.message : String(err) },
1307
- 502
1308
- );
1309
- }
1310
- }
1311
- })
1312
- );
1313
- routes.push(
1314
- registerApiRoute("/web/github/projects/:id/settings", {
1315
- method: "GET",
1316
- requiresAuth: false,
1317
- handler: async (c) => {
1318
- const loaded = await loadOrgProject({ github, auth, c: loose(c) });
1319
- if ("response" in loaded) return loaded.response;
1320
- return c.json({ setupCommand: loaded.project.setupCommand });
1321
- }
1322
- })
1323
- );
1324
- routes.push(
1325
- registerApiRoute("/web/github/projects/:id/settings", {
1326
- method: "POST",
1327
- requiresAuth: false,
1328
- handler: async (c) => {
1329
- const loaded = await loadOrgProject({ github, auth, c: loose(c) });
1330
- if ("response" in loaded) return loaded.response;
1331
- let body;
1332
- try {
1333
- body = await c.req.json();
1334
- } catch {
1335
- return c.json({ error: "Invalid JSON body" }, 400);
1336
- }
1337
- if (body.setupCommand !== null && typeof body.setupCommand !== "string") {
1338
- return c.json({ error: "Invalid setupCommand" }, 400);
1339
- }
1340
- if (typeof body.setupCommand === "string" && body.setupCommand.length > 2e3) {
1341
- return c.json({ error: "setupCommand too long (max 2000 characters)" }, 400);
1342
- }
1343
- if (typeof body.setupCommand === "string" && /[\0-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(body.setupCommand)) {
1344
- return c.json({ error: "setupCommand contains control characters" }, 400);
1345
- }
1346
- const setupCommand = typeof body.setupCommand === "string" && body.setupCommand.trim().length > 0 ? body.setupCommand.trim() : null;
1347
- await github.sourceControlStorage.projectRepositories.update({
1348
- orgId: loaded.project.installation.orgId,
1349
- id: loaded.project.id,
1350
- input: { setupCommand }
1351
- });
1352
- return c.json({ setupCommand });
1353
- }
1354
- })
1355
- );
1356
- const parsePatKind = (value) => {
1357
- if (value === void 0 || value === null || value === "default") return "default";
1358
- if (value === "reviewer") return "reviewer";
1359
- return null;
1360
- };
1361
- routes.push(
1362
- registerApiRoute("/web/github/pat", {
1363
- method: "GET",
1364
- requiresAuth: false,
1365
- handler: async (c) => {
1366
- const resolved = await resolveOrgTenant(loose(c), auth);
1367
- if ("response" in resolved) return resolved.response;
1368
- return c.json(await getGithubPatStatus(() => github.integrationStorage, resolved.tenant.orgId));
1369
- }
1370
- }),
1371
- registerApiRoute("/web/github/pat", {
1372
- method: "POST",
1373
- requiresAuth: false,
1374
- handler: async (c) => {
1375
- const resolved = await resolveOrgTenant(loose(c), auth);
1376
- if ("response" in resolved) return resolved.response;
1377
- let body;
1378
- try {
1379
- body = await c.req.json();
1380
- } catch {
1381
- return c.json({ error: "Invalid JSON body" }, 400);
1382
- }
1383
- const kind = parsePatKind(body.kind);
1384
- if (!kind) return c.json({ error: "kind must be 'default' or 'reviewer'" }, 400);
1385
- const token = typeof body.token === "string" ? body.token.trim() : "";
1386
- if (!token) return c.json({ error: "A token is required" }, 400);
1387
- if (token.length > 500) return c.json({ error: "Token too long (max 500 characters)" }, 400);
1388
- if (/\s/.test(token)) return c.json({ error: "Token must not contain whitespace" }, 400);
1389
- await setGithubPat(github.integrationStorage, resolved.tenant.orgId, token, kind);
1390
- return c.json(await getGithubPatStatus(() => github.integrationStorage, resolved.tenant.orgId));
1391
- }
1392
- }),
1393
- registerApiRoute("/web/github/pat", {
1394
- method: "DELETE",
1395
- requiresAuth: false,
1396
- handler: async (c) => {
1397
- const resolved = await resolveOrgTenant(loose(c), auth);
1398
- if ("response" in resolved) return resolved.response;
1399
- const kind = parsePatKind(c.req.query("kind"));
1400
- if (!kind) return c.json({ error: "kind must be 'default' or 'reviewer'" }, 400);
1401
- await clearGithubPat(github.integrationStorage, resolved.tenant.orgId, kind);
1402
- return c.json(await getGithubPatStatus(() => github.integrationStorage, resolved.tenant.orgId));
1403
- }
1404
- })
1405
- );
1406
- routes.push(...buildProjectGitRoutes({ github, auth, fleet, emitAudit }));
1407
- return routes;
1408
- }
225
+ const routes = [];
226
+ const { auth, fleet, storage, github, stateSigner, emitAudit } = options;
227
+ const diagnostics = () => getGithubFeatureDiagnostics({
228
+ github,
229
+ auth,
230
+ appDbConfigured: storage !== void 0,
231
+ stateSigner,
232
+ fleet
233
+ });
234
+ routes.push(registerApiRoute("/web/github/status", {
235
+ method: "GET",
236
+ requiresAuth: false,
237
+ handler: async (c) => {
238
+ if (!isGithubFeatureEnabled({
239
+ github,
240
+ auth
241
+ }) || !github || !stateSigner) return c.json({
242
+ enabled: false,
243
+ connected: false,
244
+ installations: [],
245
+ reason: "missing_config",
246
+ diagnostics: diagnostics()
247
+ });
248
+ await auth.ensureUser(loose(c));
249
+ const tenant = auth.tenant(loose(c));
250
+ if (!tenant) return c.json({
251
+ error: "unauthorized",
252
+ reason: "auth_required"
253
+ }, 401);
254
+ if (!tenant.orgId) return c.json({
255
+ enabled: true,
256
+ sandboxEnabled: fleet.enabled,
257
+ organizationRequired: true,
258
+ connected: false,
259
+ installations: [],
260
+ reason: "organization_required",
261
+ diagnostics: diagnostics()
262
+ });
263
+ const rows = options.github ? await options.github.sourceControlStorage.installations.list({ orgId: tenant.orgId }) : [];
264
+ const connected = rows.length > 0;
265
+ return c.json({
266
+ enabled: true,
267
+ sandboxEnabled: fleet.enabled,
268
+ connected,
269
+ installations: rows.map((r) => ({
270
+ installationId: Number(r.externalId),
271
+ accountLogin: r.accountName,
272
+ accountType: r.accountType
273
+ })),
274
+ reason: connected ? "ready" : "not_connected",
275
+ diagnostics: diagnostics()
276
+ });
277
+ }
278
+ }));
279
+ if (!isGithubFeatureEnabled({
280
+ github,
281
+ auth
282
+ }) || !github || !stateSigner) return routes;
283
+ const signState = (orgId, userId) => stateSigner.sign(orgId, userId);
284
+ const verifyState = (state) => stateSigner.verify(state);
285
+ const { runIssueTriage } = options;
286
+ const runBoardIssueTriage = runIssueTriage ? async (input) => {
287
+ if (!input.resourceId || !input.projectPath) throw new Error("GitHub issue triage requires an explicit Factory project repository");
288
+ await github.addIssueLabels(input.installationId, input.repository, input.issueNumber, ["auto-triaged"]);
289
+ return runIssueTriage({
290
+ ...input,
291
+ defaultModelId: input.defaultModelId ?? await resolveFactoryDefaultModelId(options.projects, input.resourceId),
292
+ labels: input.labels.includes("auto-triaged") ? input.labels : [...input.labels, "auto-triaged"]
293
+ });
294
+ } : void 0;
295
+ routes.push(registerApiRoute("/web/github/subscriptions", {
296
+ method: "GET",
297
+ handler: async (c) => {
298
+ await auth.ensureUser(loose(c));
299
+ const tenant = auth.tenant(loose(c));
300
+ if (!tenant?.orgId) return c.json({ error: "unauthorized" }, 401);
301
+ const resourceId = c.req.query("resourceId");
302
+ const threadId = c.req.query("threadId");
303
+ const sessionScope = c.req.query("scope");
304
+ if (!resourceId || !threadId) return c.json({ error: "resourceId and threadId are required" }, 400);
305
+ const subscriptions = await listPullRequestSubscriptionsForThread({
306
+ orgId: tenant.orgId,
307
+ resourceId,
308
+ threadId,
309
+ sessionScope
310
+ }, github.integrationStorage);
311
+ return c.json({ subscriptions: subscriptions.map((subscription) => ({
312
+ id: subscription.id,
313
+ repoFullName: subscription.data.repositorySlug,
314
+ pullRequestNumber: Number(subscription.data.changeRequestId),
315
+ status: subscription.status,
316
+ url: `https://github.com/${subscription.data.repositorySlug}/pull/${subscription.data.changeRequestId}`
317
+ })) });
318
+ }
319
+ }), registerApiRoute("/web/github/webhook", {
320
+ method: "POST",
321
+ requiresAuth: false,
322
+ handler: async (c) => {
323
+ const result = await handleGithubWebhook(loose(c), {
324
+ github,
325
+ runIssueTriage: runBoardIssueTriage,
326
+ ingestFactoryEvent: options.ingestFactoryEvent,
327
+ ...options.controller ? {
328
+ controller: options.controller,
329
+ onTargetError: (subscription, error) => {
330
+ console.warn(`[GitHub Webhook] Delivery failed for subscription ${subscription.id} (${subscription.resourceId}/${subscription.threadId}).`, error);
331
+ }
332
+ } : {}
333
+ });
334
+ return c.json(result.body, result.status);
335
+ }
336
+ }));
337
+ const redirectUri = options.redirectUri ?? `${(options.baseUrl ?? "").replace(/\/$/, "")}/auth/github/callback`;
338
+ routes.push(registerApiRoute("/auth/github/connect", {
339
+ method: "GET",
340
+ requiresAuth: false,
341
+ handler: async (c) => {
342
+ const resolved = await resolveOrgTenant(loose(c), auth);
343
+ if ("response" in resolved) return resolved.response;
344
+ const state = signState(resolved.tenant.orgId, resolved.tenant.userId);
345
+ if (c.req.query("manage")) return c.redirect(github.buildInstallUrl(state));
346
+ return c.redirect(github.buildOAuthIdentifyUrl(state, redirectUri));
347
+ }
348
+ }));
349
+ routes.push(registerApiRoute("/auth/github/callback", {
350
+ method: "GET",
351
+ requiresAuth: false,
352
+ handler: async (c) => {
353
+ const resolved = await resolveOrgTenant(loose(c), auth);
354
+ if ("response" in resolved) return resolved.response;
355
+ const { orgId, userId } = resolved.tenant;
356
+ const state = c.req.query("state");
357
+ if (!state) return c.redirect(github.buildOAuthIdentifyUrl(signState(orgId, userId), redirectUri));
358
+ const stateTenant = verifyState(state);
359
+ if (!stateTenant || stateTenant.userId !== userId || stateTenant.orgId !== orgId) {
360
+ console.warn("[GitHub] Install callback rejected: state/tenant mismatch.", JSON.stringify({
361
+ stateValid: Boolean(stateTenant),
362
+ stateOrgId: stateTenant?.orgId,
363
+ stateUserId: stateTenant?.userId,
364
+ sessionOrgId: orgId,
365
+ sessionUserId: userId
366
+ }));
367
+ return c.redirect("/?github=error");
368
+ }
369
+ const code = c.req.query("code");
370
+ if (!code) return c.redirect(github.buildOAuthIdentifyUrl(signState(orgId, userId), redirectUri));
371
+ try {
372
+ const userToken = await github.exchangeOAuthCode(code, redirectUri);
373
+ const installations = await github.listUserInstallations(userToken);
374
+ if (installations.length === 0) return c.redirect(github.buildInstallUrl(signState(orgId, userId)));
375
+ for (const inst of installations) await github.sourceControlStorage.installations.upsert({
376
+ orgId,
377
+ connectedByUserId: userId,
378
+ externalId: inst.installationId.toString(),
379
+ accountName: inst.accountLogin,
380
+ accountType: inst.accountType
381
+ });
382
+ } catch (error) {
383
+ console.warn(`[GitHub] Install callback failed to persist installations for org ${orgId} / user ${userId}.`, error);
384
+ return c.redirect("/?github=error");
385
+ }
386
+ return c.redirect("/?github=connected");
387
+ }
388
+ }));
389
+ routes.push(registerApiRoute("/web/github/repos", {
390
+ method: "GET",
391
+ requiresAuth: false,
392
+ handler: async (c) => {
393
+ const resolved = await resolveOrgTenant(loose(c), auth);
394
+ if ("response" in resolved) return resolved.response;
395
+ const orgId = resolved.tenant.orgId;
396
+ const installs = await github.sourceControlStorage.installations.list({ orgId });
397
+ const query = (c.req.query("q") ?? "").toLowerCase();
398
+ const listed = await Promise.all(installs.map(async (inst) => {
399
+ try {
400
+ return {
401
+ inst,
402
+ list: await github.listInstallationRepos(Number(inst.externalId))
403
+ };
404
+ } catch (err) {
405
+ if (err.status !== 404) throw err;
406
+ console.error(`[Mastra Factory] pruning stale GitHub installation ${inst.externalId} (404 from GitHub)`);
407
+ await github.sourceControlStorage.installations.delete({
408
+ orgId,
409
+ id: inst.id
410
+ });
411
+ return {
412
+ inst,
413
+ list: []
414
+ };
415
+ }
416
+ }));
417
+ const matches = [];
418
+ const seenRepositoryIds = /* @__PURE__ */ new Set();
419
+ for (const { inst, list } of listed) for (const repo of list) {
420
+ if (query && !repo.fullName.toLowerCase().includes(query)) continue;
421
+ if (seenRepositoryIds.has(repo.id)) continue;
422
+ seenRepositoryIds.add(repo.id);
423
+ matches.push({
424
+ inst,
425
+ repo
426
+ });
427
+ }
428
+ const repos = new Array(matches.length);
429
+ const upsertConcurrency = 10;
430
+ for (let start = 0; start < matches.length; start += upsertConcurrency) await Promise.all(matches.slice(start, start + upsertConcurrency).map(async ({ inst, repo }, offset) => {
431
+ const repository = await github.sourceControlStorage.repositories.upsert({
432
+ orgId,
433
+ input: {
434
+ installationId: inst.id,
435
+ externalId: repo.id.toString(),
436
+ slug: repo.fullName,
437
+ defaultBranch: isValidGitRef(repo.defaultBranch) ? repo.defaultBranch : "main",
438
+ providerMetadata: {
439
+ private: repo.private,
440
+ owner: repo.owner
441
+ }
442
+ }
443
+ });
444
+ repos[start + offset] = {
445
+ ...repo,
446
+ installationStorageId: inst.id,
447
+ repositoryStorageId: repository.id,
448
+ sandboxProvider: fleet.provider,
449
+ sandboxWorkdir: fleet.computeWorkdir(repo.fullName)
450
+ };
451
+ }));
452
+ return c.json({ repos });
453
+ }
454
+ }));
455
+ routes.push(registerApiRoute("/web/github/projects/:id/ensure", {
456
+ method: "POST",
457
+ requiresAuth: false,
458
+ handler: async (c) => {
459
+ const resolved = await resolveOrgTenant(loose(c), auth);
460
+ if ("response" in resolved) return resolved.response;
461
+ const { orgId, userId } = resolved.tenant;
462
+ if (!fleet.enabled) return c.json({
463
+ error: "sandbox_not_configured",
464
+ message: "No sandbox provider is configured."
465
+ }, 503);
466
+ const projectRepositoryId = c.req.param("id");
467
+ if (!projectRepositoryId) return c.json({ error: "Project repository not found" }, 404);
468
+ const project = await resolveProjectRepository({
469
+ github,
470
+ orgId,
471
+ projectRepositoryId
472
+ });
473
+ if (!project) return c.json({ error: "Project repository not found" }, 404);
474
+ if ((c.req.header("accept") ?? "").includes("text/event-stream")) return streamSSE(loose(c), async (stream) => {
475
+ try {
476
+ const result = await prepareProject({
477
+ github,
478
+ fleet,
479
+ project,
480
+ userId,
481
+ onProgress: (ev) => void stream.writeSSE({
482
+ event: "progress",
483
+ data: JSON.stringify(ev)
484
+ })
485
+ });
486
+ await stream.writeSSE({
487
+ event: "done",
488
+ data: JSON.stringify(result)
489
+ });
490
+ } catch (err) {
491
+ await stream.writeSSE({
492
+ event: "error",
493
+ data: JSON.stringify(ensureErrorPayload(err).body)
494
+ });
495
+ }
496
+ });
497
+ try {
498
+ const result = await prepareProject({
499
+ github,
500
+ fleet,
501
+ project,
502
+ userId
503
+ });
504
+ return c.json(result);
505
+ } catch (err) {
506
+ const { status, body } = ensureErrorPayload(err);
507
+ return c.json(body, status);
508
+ }
509
+ }
510
+ }));
511
+ routes.push(registerApiRoute("/web/github/projects/:id/issues", {
512
+ method: "GET",
513
+ requiresAuth: false,
514
+ handler: async (c) => {
515
+ const loaded = await loadOrgProject({
516
+ github,
517
+ auth,
518
+ c: loose(c)
519
+ });
520
+ if ("response" in loaded) return loaded.response;
521
+ const page = parseListPage(c.req.query("page"));
522
+ if (page === null) return c.json({ error: "invalid_page" }, 400);
523
+ const label = parseIssueLabelFilter(c.req.query("label"));
524
+ if (label === null) return c.json({ error: "invalid_label" }, 400);
525
+ try {
526
+ const { issues, nextCursor } = await github.intake.listIssues({
527
+ connection: {
528
+ type: "app-installation",
529
+ installationId: Number(loaded.project.installation.externalId)
530
+ },
531
+ sourceIds: [loaded.project.repository.slug],
532
+ labels: label ? [label] : void 0,
533
+ cursor: String(page)
534
+ });
535
+ const responseIssues = issues.map((issue) => ({
536
+ number: Number(issue.id),
537
+ title: issue.title,
538
+ url: issue.url,
539
+ author: issue.author,
540
+ labels: issue.labels,
541
+ comments: issue.commentCount ?? 0,
542
+ createdAt: issue.createdAt,
543
+ updatedAt: issue.updatedAt
544
+ }));
545
+ await ingestPolledEvents(responseIssues.map((issue) => polledIssueEvent(loaded.project, issue)), options.ingestFactoryEvent);
546
+ return c.json({
547
+ issues: responseIssues,
548
+ nextPage: nextCursor === null ? null : Number(nextCursor)
549
+ });
550
+ } catch (err) {
551
+ return c.json({
552
+ error: "github_fetch_failed",
553
+ message: err instanceof Error ? err.message : String(err)
554
+ }, 502);
555
+ }
556
+ }
557
+ }));
558
+ routes.push(registerApiRoute("/web/github/projects/:id/issues/:number/triage", {
559
+ method: "POST",
560
+ requiresAuth: false,
561
+ handler: async (c) => {
562
+ const owned = await loadOwnedProject({
563
+ github,
564
+ auth,
565
+ fleet,
566
+ c: loose(c)
567
+ });
568
+ if ("response" in owned) return owned.response;
569
+ const { project, sandboxRow } = owned;
570
+ const issueNumber = parseIssueNumberParam(c.req.param("number"));
571
+ if (issueNumber === null) return c.json({ error: "invalid_issue_number" }, 400);
572
+ let body;
573
+ try {
574
+ body = await c.req.json();
575
+ } catch {
576
+ return c.json({ error: "Invalid JSON body" }, 400);
577
+ }
578
+ if (typeof body.title !== "string" || body.title.trim().length === 0 || body.title.length > 5e3) return c.json({ error: "invalid_title" }, 400);
579
+ if (typeof body.url !== "string" || body.url.trim().length === 0 || body.url.length > 2048 || !isCanonicalGithubIssueUrl(body.url, project.repository.slug, issueNumber)) return c.json({ error: "invalid_url" }, 400);
580
+ if (!runIssueTriage) return c.json({ error: "triage_unavailable" }, 503);
581
+ const branch = `factory/issue-${issueNumber}`;
582
+ const projectPath = computeWorktreePath(sandboxRow.sandboxWorkdir, branch);
583
+ await github.addIssueLabels(Number(project.installation.externalId), project.repository.slug, issueNumber, ["auto-triaged"]);
584
+ const result = await runIssueTriage({
585
+ repository: project.repository.slug,
586
+ issueNumber,
587
+ issueTitle: body.title,
588
+ issueUrl: body.url,
589
+ labels: parseStringList(body.labels),
590
+ installationId: Number(project.installation.externalId),
591
+ resourceId: project.factoryProjectId,
592
+ projectPath,
593
+ branch,
594
+ defaultModelId: await resolveFactoryDefaultModelId(options.projects, project.factoryProjectId)
595
+ });
596
+ await emitAudit?.({
597
+ context: loose(c),
598
+ input: {
599
+ action: "factory.triage.started",
600
+ factoryProjectId: project.factoryProjectId,
601
+ projectRepositoryId: project.id,
602
+ targets: [{
603
+ type: "issue",
604
+ id: String(issueNumber),
605
+ name: body.title
606
+ }],
607
+ metadata: {
608
+ issueNumber,
609
+ branch,
610
+ threadId: result.threadId
611
+ }
612
+ }
613
+ });
614
+ return c.json({
615
+ ok: true,
616
+ threadId: result.threadId,
617
+ projectPath: result.projectPath ?? projectPath,
618
+ branch: result.branch ?? branch
619
+ }, 202);
620
+ }
621
+ }));
622
+ routes.push(registerApiRoute("/web/github/projects/:id/prs", {
623
+ method: "GET",
624
+ requiresAuth: false,
625
+ handler: async (c) => {
626
+ const loaded = await loadOrgProject({
627
+ github,
628
+ auth,
629
+ c: loose(c)
630
+ });
631
+ if ("response" in loaded) return loaded.response;
632
+ const page = parseListPage(c.req.query("page"));
633
+ if (page === null) return c.json({ error: "invalid_page" }, 400);
634
+ try {
635
+ const { pullRequests, nextCursor } = await github.versionControl.listPullRequests({
636
+ connection: {
637
+ type: "app-installation",
638
+ installationId: Number(loaded.project.installation.externalId)
639
+ },
640
+ sourceId: loaded.project.repository.slug,
641
+ includeDrafts: false,
642
+ cursor: String(page)
643
+ });
644
+ const responsePullRequests = pullRequests.map((pr) => ({
645
+ number: Number(pr.id),
646
+ title: pr.title,
647
+ url: pr.url,
648
+ author: pr.author,
649
+ baseBranch: pr.baseBranch,
650
+ headBranch: pr.headBranch,
651
+ createdAt: pr.createdAt,
652
+ updatedAt: pr.updatedAt
653
+ }));
654
+ await ingestPolledEvents(responsePullRequests.map((pullRequest) => polledPullRequestEvent(loaded.project, pullRequest)), options.ingestFactoryEvent);
655
+ return c.json({
656
+ pullRequests: responsePullRequests,
657
+ nextPage: nextCursor === null ? null : Number(nextCursor)
658
+ });
659
+ } catch (err) {
660
+ return c.json({
661
+ error: "github_fetch_failed",
662
+ message: err instanceof Error ? err.message : String(err)
663
+ }, 502);
664
+ }
665
+ }
666
+ }));
667
+ routes.push(registerApiRoute("/web/github/projects/:id/settings", {
668
+ method: "GET",
669
+ requiresAuth: false,
670
+ handler: async (c) => {
671
+ const loaded = await loadOrgProject({
672
+ github,
673
+ auth,
674
+ c: loose(c)
675
+ });
676
+ if ("response" in loaded) return loaded.response;
677
+ return c.json({ setupCommand: loaded.project.setupCommand });
678
+ }
679
+ }));
680
+ routes.push(registerApiRoute("/web/github/projects/:id/settings", {
681
+ method: "POST",
682
+ requiresAuth: false,
683
+ handler: async (c) => {
684
+ const loaded = await loadOrgProject({
685
+ github,
686
+ auth,
687
+ c: loose(c)
688
+ });
689
+ if ("response" in loaded) return loaded.response;
690
+ let body;
691
+ try {
692
+ body = await c.req.json();
693
+ } catch {
694
+ return c.json({ error: "Invalid JSON body" }, 400);
695
+ }
696
+ if (body.setupCommand !== null && typeof body.setupCommand !== "string") return c.json({ error: "Invalid setupCommand" }, 400);
697
+ if (typeof body.setupCommand === "string" && body.setupCommand.length > 2e3) return c.json({ error: "setupCommand too long (max 2000 characters)" }, 400);
698
+ if (typeof body.setupCommand === "string" && /[\0-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(body.setupCommand)) return c.json({ error: "setupCommand contains control characters" }, 400);
699
+ const setupCommand = typeof body.setupCommand === "string" && body.setupCommand.trim().length > 0 ? body.setupCommand.trim() : null;
700
+ await github.sourceControlStorage.projectRepositories.update({
701
+ orgId: loaded.project.installation.orgId,
702
+ id: loaded.project.id,
703
+ input: { setupCommand }
704
+ });
705
+ return c.json({ setupCommand });
706
+ }
707
+ }));
708
+ const parsePatKind = (value) => {
709
+ if (value === void 0 || value === null || value === "default") return "default";
710
+ if (value === "reviewer") return "reviewer";
711
+ return null;
712
+ };
713
+ routes.push(registerApiRoute("/web/github/pat", {
714
+ method: "GET",
715
+ requiresAuth: false,
716
+ handler: async (c) => {
717
+ const resolved = await resolveOrgTenant(loose(c), auth);
718
+ if ("response" in resolved) return resolved.response;
719
+ return c.json(await getGithubPatStatus(() => github.integrationStorage, resolved.tenant.orgId));
720
+ }
721
+ }), registerApiRoute("/web/github/pat", {
722
+ method: "POST",
723
+ requiresAuth: false,
724
+ handler: async (c) => {
725
+ const resolved = await resolveOrgTenant(loose(c), auth);
726
+ if ("response" in resolved) return resolved.response;
727
+ let body;
728
+ try {
729
+ body = await c.req.json();
730
+ } catch {
731
+ return c.json({ error: "Invalid JSON body" }, 400);
732
+ }
733
+ const kind = parsePatKind(body.kind);
734
+ if (!kind) return c.json({ error: "kind must be 'default' or 'reviewer'" }, 400);
735
+ const token = typeof body.token === "string" ? body.token.trim() : "";
736
+ if (!token) return c.json({ error: "A token is required" }, 400);
737
+ if (token.length > 500) return c.json({ error: "Token too long (max 500 characters)" }, 400);
738
+ if (/\s/.test(token)) return c.json({ error: "Token must not contain whitespace" }, 400);
739
+ await setGithubPat(github.integrationStorage, resolved.tenant.orgId, token, kind);
740
+ return c.json(await getGithubPatStatus(() => github.integrationStorage, resolved.tenant.orgId));
741
+ }
742
+ }), registerApiRoute("/web/github/pat", {
743
+ method: "DELETE",
744
+ requiresAuth: false,
745
+ handler: async (c) => {
746
+ const resolved = await resolveOrgTenant(loose(c), auth);
747
+ if ("response" in resolved) return resolved.response;
748
+ const kind = parsePatKind(c.req.query("kind"));
749
+ if (!kind) return c.json({ error: "kind must be 'default' or 'reviewer'" }, 400);
750
+ await clearGithubPat(github.integrationStorage, resolved.tenant.orgId, kind);
751
+ return c.json(await getGithubPatStatus(() => github.integrationStorage, resolved.tenant.orgId));
752
+ }
753
+ }));
754
+ routes.push(...buildProjectGitRoutes({
755
+ github,
756
+ auth,
757
+ fleet,
758
+ emitAudit
759
+ }));
760
+ return routes;
761
+ }
762
+ /**
763
+ * Load the org-owned project for a read-only GitHub API route. Unlike
764
+ * `loadOwnedProject`, this never touches sandbox state — the issues/PR list
765
+ * routes only need the repo + installation, so they work before a sandbox is
766
+ * ever provisioned.
767
+ */
1409
768
  async function loadOrgProject(options) {
1410
- const { github, auth, c } = options;
1411
- const resolved = await resolveOrgTenant(c, auth);
1412
- if ("response" in resolved) return { response: resolved.response };
1413
- const { orgId, userId } = resolved.tenant;
1414
- const projectRepositoryId = c.req.param("id");
1415
- if (!projectRepositoryId) {
1416
- return { response: c.json({ error: "Project repository not found" }, 404) };
1417
- }
1418
- const project = await resolveProjectRepository({ github, orgId, projectRepositoryId });
1419
- if (!project) {
1420
- return { response: c.json({ error: "Project repository not found" }, 404) };
1421
- }
1422
- return { project, userId };
1423
- }
769
+ const { github, auth, c } = options;
770
+ const resolved = await resolveOrgTenant(c, auth);
771
+ if ("response" in resolved) return { response: resolved.response };
772
+ const { orgId, userId } = resolved.tenant;
773
+ const projectRepositoryId = c.req.param("id");
774
+ if (!projectRepositoryId) return { response: c.json({ error: "Project repository not found" }, 404) };
775
+ const project = await resolveProjectRepository({
776
+ github,
777
+ orgId,
778
+ projectRepositoryId
779
+ });
780
+ if (!project) return { response: c.json({ error: "Project repository not found" }, 404) };
781
+ return {
782
+ project,
783
+ userId
784
+ };
785
+ }
786
+ /** Derive a commit/author identity from the authenticated host user. */
1424
787
  function identityFromUser(user) {
1425
- const u = user;
1426
- return { name: u?.name ?? null, email: u?.email ?? null };
1427
- }
788
+ const u = user;
789
+ return {
790
+ name: u?.name ?? null,
791
+ email: u?.email ?? null
792
+ };
793
+ }
794
+ /**
795
+ * Resolve a live, started sandbox for the caller's per-user sandbox binding. The
796
+ * sandbox must already have been provisioned (`sandboxId` set) — the git write
797
+ * routes never clone, they operate on the existing checkout.
798
+ */
1428
799
  async function resolveProjectSandbox(options) {
1429
- const { fleet, sandboxRow } = options;
1430
- if (!sandboxRow.sandboxId) {
1431
- throw new MaterializeError("Project sandbox is not provisioned. Open the project first.", "clone-failed");
1432
- }
1433
- return fleet.reattachSandbox(sandboxRow.sandboxId);
1434
- }
800
+ const { fleet, sandboxRow } = options;
801
+ if (!sandboxRow.sandboxId) throw new MaterializeError("Project sandbox is not provisioned. Open the project first.", "clone-failed");
802
+ return fleet.reattachSandbox(sandboxRow.sandboxId);
803
+ }
804
+ /**
805
+ * Load (or create) the caller's per-(project,user) sandbox binding row. The
806
+ * binding inherits its workdir from the org-owned project, but `sandboxId` /
807
+ * `materializedAt` stay null until the user first opens the project.
808
+ */
1435
809
  async function loadOrCreateSandboxRow(github, project, userId) {
1436
- return github.sourceControlStorage.sandboxes.getOrCreate({ projectRepository: project, userId });
1437
- }
810
+ return github.sourceControlStorage.sandboxes.getOrCreate({
811
+ projectRepository: project,
812
+ userId
813
+ });
814
+ }
815
+ /**
816
+ * Provision/reattach the caller's sandbox and materialize the repo into it,
817
+ * emitting coarse progress events as each server step happens. Shared by both
818
+ * the JSON and SSE variants of the `/ensure` route. Throws on failure so the
819
+ * caller can shape the response (HTTP status vs SSE `error` event).
820
+ */
1438
821
  async function prepareProject(options) {
1439
- const { github, fleet, project, userId, onProgress } = options;
1440
- const sandboxRow = await loadOrCreateSandboxRow(github, project, userId);
1441
- const access = await github.versionControl.getRepositoryAccess({
1442
- orgId: project.installation.orgId,
1443
- repositoryId: project.repository.id
1444
- });
1445
- if (!access.authorization) {
1446
- throw new MaterializeError("Repository access did not include a bearer token.", "clone-failed");
1447
- }
1448
- const ghCliToken = await getGithubPat(() => github.integrationStorage, project.installation.orgId) ?? access.authorization.token;
1449
- const sandbox = await ensureProjectSandbox({
1450
- fleet,
1451
- row: sandboxRow,
1452
- storage: github.sourceControlStorage.sandboxes,
1453
- token: ghCliToken,
1454
- onProgress
1455
- });
1456
- const fresh = await github.sourceControlStorage.sandboxes.getById({ id: sandboxRow.id });
1457
- const finalRow = fresh ?? sandboxRow;
1458
- await materializeRepo({
1459
- row: finalRow,
1460
- repoInfo: { repoFullName: project.repository.slug, defaultBranch: project.defaultBranch },
1461
- sandbox,
1462
- token: access.authorization.token,
1463
- storage: github.sourceControlStorage.sandboxes,
1464
- onProgress
1465
- });
1466
- const result = {
1467
- resourceId: project.factoryProjectId,
1468
- factoryProjectId: project.factoryProjectId,
1469
- projectRepositoryId: project.id,
1470
- sandboxId: finalRow.sandboxId,
1471
- sandboxWorkdir: finalRow.sandboxWorkdir
1472
- };
1473
- const done = { phase: "done", message: "Workspace ready." };
1474
- onProgress?.(done);
1475
- return result;
1476
- }
822
+ const { github, fleet, userId, onProgress } = options;
823
+ let project = options.project;
824
+ if (project.sandboxProvider !== fleet.provider) {
825
+ const sandboxWorkdir = fleet.computeWorkdir(project.repository.slug);
826
+ await github.sourceControlStorage.projectRepositories.update({
827
+ orgId: project.installation.orgId,
828
+ id: project.id,
829
+ input: {
830
+ sandboxProvider: fleet.provider,
831
+ sandboxWorkdir
832
+ }
833
+ });
834
+ project = {
835
+ ...project,
836
+ sandboxProvider: fleet.provider,
837
+ sandboxWorkdir
838
+ };
839
+ }
840
+ let sandboxRow = await loadOrCreateSandboxRow(github, project, userId);
841
+ if (sandboxRow.sandboxWorkdir !== project.sandboxWorkdir) {
842
+ await github.sourceControlStorage.sandboxes.setWorkdir({
843
+ id: sandboxRow.id,
844
+ sandboxWorkdir: project.sandboxWorkdir
845
+ });
846
+ sandboxRow = {
847
+ ...sandboxRow,
848
+ sandboxWorkdir: project.sandboxWorkdir,
849
+ materializedAt: null
850
+ };
851
+ }
852
+ const access = await github.versionControl.getRepositoryAccess({
853
+ orgId: project.installation.orgId,
854
+ repositoryId: project.repository.id
855
+ });
856
+ if (!access.authorization) throw new MaterializeError("Repository access did not include a bearer token.", "clone-failed");
857
+ const ghCliToken = await getGithubPat(() => github.integrationStorage, project.installation.orgId) ?? access.authorization.token;
858
+ const sandbox = await ensureProjectSandbox({
859
+ fleet,
860
+ row: sandboxRow,
861
+ storage: github.sourceControlStorage.sandboxes,
862
+ token: ghCliToken,
863
+ onProgress
864
+ });
865
+ const finalRow = await github.sourceControlStorage.sandboxes.getById({ id: sandboxRow.id }) ?? sandboxRow;
866
+ await materializeRepo({
867
+ row: finalRow,
868
+ repoInfo: {
869
+ repoFullName: project.repository.slug,
870
+ defaultBranch: project.defaultBranch
871
+ },
872
+ sandbox,
873
+ token: access.authorization.token,
874
+ storage: github.sourceControlStorage.sandboxes,
875
+ onProgress
876
+ });
877
+ const result = {
878
+ resourceId: project.factoryProjectId,
879
+ factoryProjectId: project.factoryProjectId,
880
+ projectRepositoryId: project.id,
881
+ sandboxId: finalRow.sandboxId,
882
+ sandboxWorkdir: finalRow.sandboxWorkdir
883
+ };
884
+ onProgress?.({
885
+ phase: "done",
886
+ message: "Workspace ready."
887
+ });
888
+ return result;
889
+ }
890
+ /** Shape an /ensure failure into an HTTP status + JSON body (also used as the SSE error payload). */
1477
891
  function ensureErrorPayload(err) {
1478
- if (err instanceof SandboxBudgetError) {
1479
- return { status: 429, body: { error: err.code, message: err.message } };
1480
- }
1481
- if (err instanceof MaterializeError) {
1482
- return { status: 502, body: { error: err.code, message: err.message } };
1483
- }
1484
- return {
1485
- status: 500,
1486
- body: { error: "materialize_failed", message: err instanceof Error ? err.message : String(err) }
1487
- };
1488
- }
892
+ if (err instanceof SandboxBudgetError) return {
893
+ status: 429,
894
+ body: {
895
+ error: err.code,
896
+ message: err.message
897
+ }
898
+ };
899
+ if (err instanceof MaterializeError) return {
900
+ status: 502,
901
+ body: {
902
+ error: err.code,
903
+ message: err.message
904
+ }
905
+ };
906
+ return {
907
+ status: 500,
908
+ body: {
909
+ error: "materialize_failed",
910
+ message: err instanceof Error ? err.message : String(err)
911
+ }
912
+ };
913
+ }
914
+ /** Map a sandbox/worktree error to an actionable HTTP response. */
1489
915
  function gitErrorResponse(c, err) {
1490
- if (err instanceof WorktreeError) {
1491
- return c.json({ error: err.code, message: err.message }, err.code === "invalid-branch" ? 400 : 502);
1492
- }
1493
- if (err instanceof MaterializeError) {
1494
- return c.json({ error: err.code, message: err.message }, 502);
1495
- }
1496
- return c.json({ error: "git_failed", message: err instanceof Error ? err.message : String(err) }, 500);
1497
- }
916
+ if (err instanceof WorktreeError) return c.json({
917
+ error: err.code,
918
+ message: err.message
919
+ }, err.code === "invalid-branch" ? 400 : 502);
920
+ if (err instanceof MaterializeError) return c.json({
921
+ error: err.code,
922
+ message: err.message
923
+ }, 502);
924
+ return c.json({
925
+ error: "git_failed",
926
+ message: err instanceof Error ? err.message : String(err)
927
+ }, 500);
928
+ }
929
+ /**
930
+ * Load the org-owned project and the caller's per-user sandbox binding for a git
931
+ * route. Centralizes the auth + org/ownership checks every git route shares:
932
+ * the project is scoped by `(id, orgId)`, the sandbox binding by
933
+ * `(projectRepositoryId, userId)`. Returns the tenant, project, and sandbox row, or
934
+ * a ready-to-return error response.
935
+ */
1498
936
  async function loadOwnedProject(options) {
1499
- const { github, auth, fleet, c } = options;
1500
- const resolved = await resolveOrgTenant(c, auth);
1501
- if ("response" in resolved) return { response: resolved.response };
1502
- const { orgId, userId } = resolved.tenant;
1503
- if (!fleet.enabled) {
1504
- return {
1505
- response: c.json({ error: "sandbox_not_configured", message: "No sandbox provider is configured." }, 503)
1506
- };
1507
- }
1508
- const projectRepositoryId = c.req.param("id");
1509
- if (!projectRepositoryId) {
1510
- return { response: c.json({ error: "Project repository not found" }, 404) };
1511
- }
1512
- const project = await resolveProjectRepository({ github, orgId, projectRepositoryId });
1513
- if (!project) {
1514
- return { response: c.json({ error: "Project repository not found" }, 404) };
1515
- }
1516
- const sandboxRow = await loadOrCreateSandboxRow(github, project, userId);
1517
- return { orgId, userId, project, sandboxRow };
1518
- }
1519
- function buildProjectGitRoutes({
1520
- github,
1521
- auth,
1522
- fleet,
1523
- emitAudit
1524
- }) {
1525
- return [
1526
- // ── Create / list Factory sessions ──────────────────────────────────────
1527
- registerApiRoute("/web/github/projects/:id/sessions", {
1528
- method: "GET",
1529
- requiresAuth: false,
1530
- handler: async (c) => {
1531
- const resolved = await resolveOrgTenant(loose(c), auth);
1532
- if ("response" in resolved) return resolved.response;
1533
- const { orgId, userId } = resolved.tenant;
1534
- const projectRepositoryId = c.req.param("id");
1535
- const project = projectRepositoryId ? await resolveProjectRepository({ github, orgId, projectRepositoryId }) : null;
1536
- if (!project) return c.json({ error: "Project repository not found" }, 404);
1537
- const sessions = await github.sourceControlStorage.sessions.list({ projectRepositoryId: project.id, userId });
1538
- return c.json({ sessions });
1539
- }
1540
- }),
1541
- registerApiRoute("/web/github/projects/:id/sessions", {
1542
- method: "POST",
1543
- requiresAuth: false,
1544
- handler: async (c) => {
1545
- const resolved = await resolveOrgTenant(loose(c), auth);
1546
- if ("response" in resolved) return resolved.response;
1547
- const { orgId, userId } = resolved.tenant;
1548
- const projectRepositoryId = c.req.param("id");
1549
- const project = projectRepositoryId ? await resolveProjectRepository({ github, orgId, projectRepositoryId }) : null;
1550
- if (!project) return c.json({ error: "Project repository not found" }, 404);
1551
- let body;
1552
- try {
1553
- body = await c.req.json();
1554
- } catch {
1555
- return c.json({ error: "Invalid JSON body" }, 400);
1556
- }
1557
- if (!isValidGitRef(body.branch)) return c.json({ error: "Invalid branch" }, 400);
1558
- const baseBranch = body.baseBranch === void 0 ? project.defaultBranch : body.baseBranch;
1559
- if (!isValidGitRef(baseBranch)) return c.json({ error: "Invalid baseBranch" }, 400);
1560
- const session = await github.sourceControlStorage.sessions.create({
1561
- sessionId: randomUUID(),
1562
- projectRepositoryId: project.id,
1563
- orgId,
1564
- userId,
1565
- branch: body.branch,
1566
- baseBranch
1567
- });
1568
- return c.json({ session });
1569
- }
1570
- }),
1571
- registerApiRoute("/web/user-sessions/:sessionId", {
1572
- method: "GET",
1573
- requiresAuth: false,
1574
- handler: async (c) => {
1575
- const resolved = await resolveOrgTenant(loose(c), auth);
1576
- if ("response" in resolved) return resolved.response;
1577
- const session = await github.sourceControlStorage.sessions.getBySessionId(c.req.param("sessionId"));
1578
- if (!session || session.orgId !== resolved.tenant.orgId || session.userId !== resolved.tenant.userId) {
1579
- return c.json({ error: "Session not found" }, 404);
1580
- }
1581
- return c.json({ session });
1582
- }
1583
- }),
1584
- registerApiRoute("/web/user-sessions/:sessionId", {
1585
- method: "DELETE",
1586
- requiresAuth: false,
1587
- handler: async (c) => {
1588
- const resolved = await resolveOrgTenant(loose(c), auth);
1589
- if ("response" in resolved) return resolved.response;
1590
- const session = await github.sourceControlStorage.sessions.getBySessionId(c.req.param("sessionId"));
1591
- if (!session || session.orgId !== resolved.tenant.orgId || session.userId !== resolved.tenant.userId) {
1592
- return c.json({ error: "Session not found" }, 404);
1593
- }
1594
- let sandbox;
1595
- if (session.sandboxId) {
1596
- try {
1597
- sandbox = await fleet.reattachSandbox(session.sandboxId);
1598
- } catch {
1599
- }
1600
- await fleet.teardownSandbox(
1601
- {
1602
- sandboxId: session.sandboxId,
1603
- setSandboxId: async () => {
1604
- },
1605
- clear: async () => {
1606
- await github.sourceControlStorage.sessions.setSandbox({
1607
- id: session.id,
1608
- sandboxId: null,
1609
- sandboxWorkdir: session.sandboxWorkdir ?? ""
1610
- });
1611
- }
1612
- },
1613
- sandbox
1614
- );
1615
- }
1616
- await github.sourceControlStorage.sessions.delete(session.id);
1617
- return c.json({ removed: true });
1618
- }
1619
- }),
1620
- // ── Stage all + commit inside a Factory session workspace ──────────────
1621
- registerApiRoute("/web/github/projects/:id/commit", {
1622
- method: "POST",
1623
- requiresAuth: false,
1624
- handler: async (c) => {
1625
- const owned = await loadOwnedProject({ github, auth, fleet, c: loose(c) });
1626
- if ("response" in owned) return owned.response;
1627
- const { userId, project } = owned;
1628
- let body;
1629
- try {
1630
- body = await c.req.json();
1631
- } catch {
1632
- return c.json({ error: "Invalid JSON body" }, 400);
1633
- }
1634
- if (typeof body.message !== "string" || body.message.trim().length === 0 || body.message.length > 5e3) {
1635
- return c.json({ error: "Invalid message" }, 400);
1636
- }
1637
- const sessionWorkspace = await resolveSessionWorkspace(github, project.id, userId, body.sessionId);
1638
- if (!sessionWorkspace) {
1639
- return c.json({ error: "Invalid sessionId" }, 400);
1640
- }
1641
- const { workdir, sandboxBinding } = sessionWorkspace;
1642
- try {
1643
- return await withSessionOperationLock(sessionWorkspace.session.sessionId, async () => {
1644
- const sandbox = await resolveProjectSandbox({ fleet, sandboxRow: sandboxBinding });
1645
- const result = await commitAll(
1646
- sandbox,
1647
- workdir,
1648
- body.message,
1649
- identityFromUser(await auth.ensureUser(loose(c)))
1650
- );
1651
- if (result.committed) {
1652
- await emitAudit?.({
1653
- context: loose(c),
1654
- input: {
1655
- action: "factory.git.commit",
1656
- factoryProjectId: project.factoryProjectId,
1657
- projectRepositoryId: project.id,
1658
- targets: [{ type: "session", id: sessionWorkspace.session.sessionId }],
1659
- metadata: { sessionId: sessionWorkspace.session.sessionId }
1660
- }
1661
- });
1662
- }
1663
- return c.json({ committed: result.committed });
1664
- });
1665
- } catch (err) {
1666
- return gitErrorResponse(loose(c), err);
1667
- }
1668
- }
1669
- }),
1670
- // ── Push a branch back to GitHub ────────────────────────────────────────
1671
- registerApiRoute("/web/github/projects/:id/push", {
1672
- method: "POST",
1673
- requiresAuth: false,
1674
- handler: async (c) => {
1675
- const owned = await loadOwnedProject({ github, auth, fleet, c: loose(c) });
1676
- if ("response" in owned) return owned.response;
1677
- const { orgId, userId, project } = owned;
1678
- let body;
1679
- try {
1680
- body = await c.req.json();
1681
- } catch {
1682
- return c.json({ error: "Invalid JSON body" }, 400);
1683
- }
1684
- if (!isValidGitRef(body.branch)) {
1685
- return c.json({ error: "Invalid branch" }, 400);
1686
- }
1687
- const branch = body.branch;
1688
- const sessionWorkspace = await resolveSessionWorkspace(github, project.id, userId, body.sessionId);
1689
- if (!sessionWorkspace) {
1690
- return c.json({ error: "Invalid sessionId" }, 400);
1691
- }
1692
- const { workdir, sandboxBinding } = sessionWorkspace;
1693
- try {
1694
- return await withSessionOperationLock(sessionWorkspace.session.sessionId, async () => {
1695
- const sandbox = await resolveProjectSandbox({ fleet, sandboxRow: sandboxBinding });
1696
- const access = await github.versionControl.getRepositoryAccess({
1697
- orgId,
1698
- repositoryId: project.repository.id
1699
- });
1700
- if (!access.authorization) throw new Error("Repository access did not include a bearer token.");
1701
- await pushBranch(sandbox, workdir, branch, access.authorization.token, project.repository.slug);
1702
- await emitAudit?.({
1703
- context: loose(c),
1704
- input: {
1705
- action: "factory.git.push",
1706
- factoryProjectId: project.factoryProjectId,
1707
- projectRepositoryId: project.id,
1708
- targets: [{ type: "branch", id: branch }],
1709
- metadata: { branch, sessionId: sessionWorkspace.session.sessionId }
1710
- }
1711
- });
1712
- return c.json({ pushed: true, branch });
1713
- });
1714
- } catch (err) {
1715
- return gitErrorResponse(loose(c), err);
1716
- }
1717
- }
1718
- }),
1719
- // ── Open a pull request through the version-control capability ─────────
1720
- registerApiRoute("/web/github/projects/:id/pr", {
1721
- method: "POST",
1722
- requiresAuth: false,
1723
- handler: async (c) => {
1724
- const owned = await loadOwnedProject({ github, auth, fleet, c: loose(c) });
1725
- if ("response" in owned) return owned.response;
1726
- const { orgId, userId, project } = owned;
1727
- let body;
1728
- try {
1729
- body = await c.req.json();
1730
- } catch {
1731
- return c.json({ error: "Invalid JSON body" }, 400);
1732
- }
1733
- if (!isValidGitRef(body.branch)) {
1734
- return c.json({ error: "Invalid branch" }, 400);
1735
- }
1736
- const base = body.base === void 0 ? project.defaultBranch : body.base;
1737
- if (!isValidGitRef(base)) {
1738
- return c.json({ error: "Invalid base" }, 400);
1739
- }
1740
- if (typeof body.title !== "string" || body.title.trim().length === 0 || body.title.length > 256) {
1741
- return c.json({ error: "Invalid title" }, 400);
1742
- }
1743
- if (body.body !== void 0 && (typeof body.body !== "string" || body.body.length > 65536)) {
1744
- return c.json({ error: "Invalid body" }, 400);
1745
- }
1746
- const head = body.branch;
1747
- const title = body.title;
1748
- const prBody = body.body;
1749
- const sessionWorkspace = await resolveSessionWorkspace(github, project.id, userId, body.sessionId);
1750
- if (!sessionWorkspace) {
1751
- return c.json({ error: "Invalid sessionId" }, 400);
1752
- }
1753
- try {
1754
- return await withSessionOperationLock(sessionWorkspace.session.sessionId, async () => {
1755
- const result = await github.versionControl.createPullRequest({
1756
- connection: {
1757
- type: "app-installation",
1758
- installationId: Number(project.installation.externalId)
1759
- },
1760
- sourceId: project.repository.slug,
1761
- baseBranch: base,
1762
- headBranch: head,
1763
- title,
1764
- body: prBody,
1765
- actingUserId: userId
1766
- });
1767
- await emitAudit?.({
1768
- context: loose(c),
1769
- input: {
1770
- action: "factory.git.pr_opened",
1771
- factoryProjectId: project.factoryProjectId,
1772
- projectRepositoryId: project.id,
1773
- targets: [{ type: "pull_request", id: result.url, name: title }],
1774
- metadata: { branch: head, base, url: result.url }
1775
- }
1776
- });
1777
- const pullRequestNumber = pullRequestNumberFromUrl(result.url, project.repository.slug);
1778
- if (pullRequestNumber) {
1779
- const sessionId = sessionWorkspace.session.sessionId;
1780
- await subscribeToPullRequest(
1781
- {
1782
- orgId,
1783
- installationExternalId: project.installation.externalId,
1784
- projectRepositoryId: project.id,
1785
- repositoryExternalId: project.repository.externalId,
1786
- repositorySlug: project.repository.slug,
1787
- changeRequestId: pullRequestNumber.toString(),
1788
- sessionId,
1789
- ownerId: userId,
1790
- resourceId: sessionId,
1791
- threadId: sessionId,
1792
- source: "factory-pr-create",
1793
- subscribedByUserId: userId
1794
- },
1795
- github.integrationStorage
1796
- ).catch((error) => {
1797
- console.warn(
1798
- `[GitHub] Pull request ${result.url} was created but automatic subscription failed.`,
1799
- error
1800
- );
1801
- });
1802
- }
1803
- return c.json({ url: result.url });
1804
- });
1805
- } catch (err) {
1806
- return c.json(
1807
- { error: "github_pr_create_failed", message: err instanceof Error ? err.message : String(err) },
1808
- 502
1809
- );
1810
- }
1811
- }
1812
- }),
1813
- // ── Tear down the caller's sandbox for a project ────────────────────────
1814
- // Per-user teardown only: drops the caller's `(project, user)` sandbox
1815
- // binding and stops the VM, freeing a slot in the per-replica budget. Project
1816
- // deletion at the org level is out of scope (org admin model is later).
1817
- registerApiRoute("/web/github/projects/:id/sandbox", {
1818
- method: "DELETE",
1819
- requiresAuth: false,
1820
- handler: async (c) => {
1821
- const owned = await loadOwnedProject({ github, auth, fleet, c: loose(c) });
1822
- if ("response" in owned) return owned.response;
1823
- const { sandboxRow } = owned;
1824
- if (!sandboxRow.sandboxId) {
1825
- return c.json({ tornDown: false });
1826
- }
1827
- try {
1828
- return await withSessionOperationLock(`sandbox:${sandboxRow.id}`, async () => {
1829
- const sandbox = await fleet.reattachSandbox(sandboxRow.sandboxId);
1830
- await teardownProjectSandbox({
1831
- fleet,
1832
- row: sandboxRow,
1833
- storage: github.sourceControlStorage.sandboxes,
1834
- sandbox
1835
- });
1836
- return c.json({ tornDown: true });
1837
- });
1838
- } catch (err) {
1839
- return gitErrorResponse(loose(c), err);
1840
- }
1841
- }
1842
- })
1843
- ];
1844
- }
937
+ const { github, auth, fleet, c } = options;
938
+ const resolved = await resolveOrgTenant(c, auth);
939
+ if ("response" in resolved) return { response: resolved.response };
940
+ const { orgId, userId } = resolved.tenant;
941
+ if (!fleet.enabled) return { response: c.json({
942
+ error: "sandbox_not_configured",
943
+ message: "No sandbox provider is configured."
944
+ }, 503) };
945
+ const projectRepositoryId = c.req.param("id");
946
+ if (!projectRepositoryId) return { response: c.json({ error: "Project repository not found" }, 404) };
947
+ const project = await resolveProjectRepository({
948
+ github,
949
+ orgId,
950
+ projectRepositoryId
951
+ });
952
+ if (!project) return { response: c.json({ error: "Project repository not found" }, 404) };
953
+ return {
954
+ orgId,
955
+ userId,
956
+ project,
957
+ sandboxRow: await loadOrCreateSandboxRow(github, project, userId)
958
+ };
959
+ }
960
+ function buildProjectGitRoutes({ github, auth, fleet, emitAudit }) {
961
+ return [
962
+ registerApiRoute("/web/github/projects/:id/sessions", {
963
+ method: "GET",
964
+ requiresAuth: false,
965
+ handler: async (c) => {
966
+ const resolved = await resolveOrgTenant(loose(c), auth);
967
+ if ("response" in resolved) return resolved.response;
968
+ const { orgId, userId } = resolved.tenant;
969
+ const projectRepositoryId = c.req.param("id");
970
+ const project = projectRepositoryId ? await resolveProjectRepository({
971
+ github,
972
+ orgId,
973
+ projectRepositoryId
974
+ }) : null;
975
+ if (!project) return c.json({ error: "Project repository not found" }, 404);
976
+ const sessions = await github.sourceControlStorage.sessions.list({
977
+ projectRepositoryId: project.id,
978
+ userId
979
+ });
980
+ return c.json({ sessions });
981
+ }
982
+ }),
983
+ registerApiRoute("/web/github/projects/:id/sessions", {
984
+ method: "POST",
985
+ requiresAuth: false,
986
+ handler: async (c) => {
987
+ const resolved = await resolveOrgTenant(loose(c), auth);
988
+ if ("response" in resolved) return resolved.response;
989
+ const { orgId, userId } = resolved.tenant;
990
+ const projectRepositoryId = c.req.param("id");
991
+ const project = projectRepositoryId ? await resolveProjectRepository({
992
+ github,
993
+ orgId,
994
+ projectRepositoryId
995
+ }) : null;
996
+ if (!project) return c.json({ error: "Project repository not found" }, 404);
997
+ let body;
998
+ try {
999
+ body = await c.req.json();
1000
+ } catch {
1001
+ return c.json({ error: "Invalid JSON body" }, 400);
1002
+ }
1003
+ if (!isValidGitRef$1(body.branch)) return c.json({ error: "Invalid branch" }, 400);
1004
+ const baseBranch = body.baseBranch === void 0 ? project.defaultBranch : body.baseBranch;
1005
+ if (!isValidGitRef$1(baseBranch)) return c.json({ error: "Invalid baseBranch" }, 400);
1006
+ const session = await github.sourceControlStorage.sessions.create({
1007
+ sessionId: randomUUID(),
1008
+ projectRepositoryId: project.id,
1009
+ orgId,
1010
+ userId,
1011
+ branch: body.branch,
1012
+ baseBranch
1013
+ });
1014
+ return c.json({ session });
1015
+ }
1016
+ }),
1017
+ registerApiRoute("/web/user-sessions/:sessionId", {
1018
+ method: "GET",
1019
+ requiresAuth: false,
1020
+ handler: async (c) => {
1021
+ const resolved = await resolveOrgTenant(loose(c), auth);
1022
+ if ("response" in resolved) return resolved.response;
1023
+ const session = await github.sourceControlStorage.sessions.getBySessionId(c.req.param("sessionId"));
1024
+ if (!session || session.orgId !== resolved.tenant.orgId || session.userId !== resolved.tenant.userId) return c.json({ error: "Session not found" }, 404);
1025
+ return c.json({ session });
1026
+ }
1027
+ }),
1028
+ registerApiRoute("/web/user-sessions/:sessionId", {
1029
+ method: "DELETE",
1030
+ requiresAuth: false,
1031
+ handler: async (c) => {
1032
+ const resolved = await resolveOrgTenant(loose(c), auth);
1033
+ if ("response" in resolved) return resolved.response;
1034
+ const session = await github.sourceControlStorage.sessions.getBySessionId(c.req.param("sessionId"));
1035
+ if (!session || session.orgId !== resolved.tenant.orgId || session.userId !== resolved.tenant.userId) return c.json({ error: "Session not found" }, 404);
1036
+ let sandbox;
1037
+ if (session.sandboxId) {
1038
+ try {
1039
+ sandbox = await fleet.reattachSandbox(session.sandboxId);
1040
+ } catch {}
1041
+ await fleet.teardownSandbox({
1042
+ sandboxId: session.sandboxId,
1043
+ setSandboxId: async () => {},
1044
+ clear: async () => {
1045
+ await github.sourceControlStorage.sessions.setSandbox({
1046
+ id: session.id,
1047
+ sandboxId: null,
1048
+ sandboxWorkdir: session.sandboxWorkdir ?? ""
1049
+ });
1050
+ }
1051
+ }, sandbox);
1052
+ }
1053
+ await github.sourceControlStorage.sessions.delete(session.id);
1054
+ return c.json({ removed: true });
1055
+ }
1056
+ }),
1057
+ registerApiRoute("/web/github/projects/:id/commit", {
1058
+ method: "POST",
1059
+ requiresAuth: false,
1060
+ handler: async (c) => {
1061
+ const owned = await loadOwnedProject({
1062
+ github,
1063
+ auth,
1064
+ fleet,
1065
+ c: loose(c)
1066
+ });
1067
+ if ("response" in owned) return owned.response;
1068
+ const { userId, project } = owned;
1069
+ let body;
1070
+ try {
1071
+ body = await c.req.json();
1072
+ } catch {
1073
+ return c.json({ error: "Invalid JSON body" }, 400);
1074
+ }
1075
+ if (typeof body.message !== "string" || body.message.trim().length === 0 || body.message.length > 5e3) return c.json({ error: "Invalid message" }, 400);
1076
+ const sessionWorkspace = await resolveSessionWorkspace(github, project.id, userId, body.sessionId);
1077
+ if (!sessionWorkspace) return c.json({ error: "Invalid sessionId" }, 400);
1078
+ const { workdir, sandboxBinding } = sessionWorkspace;
1079
+ try {
1080
+ return await withSessionOperationLock(sessionWorkspace.session.sessionId, async () => {
1081
+ const result = await commitAll(await resolveProjectSandbox({
1082
+ fleet,
1083
+ sandboxRow: sandboxBinding
1084
+ }), workdir, body.message, identityFromUser(await auth.ensureUser(loose(c))));
1085
+ if (result.committed) await emitAudit?.({
1086
+ context: loose(c),
1087
+ input: {
1088
+ action: "factory.git.commit",
1089
+ factoryProjectId: project.factoryProjectId,
1090
+ projectRepositoryId: project.id,
1091
+ targets: [{
1092
+ type: "session",
1093
+ id: sessionWorkspace.session.sessionId
1094
+ }],
1095
+ metadata: { sessionId: sessionWorkspace.session.sessionId }
1096
+ }
1097
+ });
1098
+ return c.json({ committed: result.committed });
1099
+ });
1100
+ } catch (err) {
1101
+ return gitErrorResponse(loose(c), err);
1102
+ }
1103
+ }
1104
+ }),
1105
+ registerApiRoute("/web/github/projects/:id/push", {
1106
+ method: "POST",
1107
+ requiresAuth: false,
1108
+ handler: async (c) => {
1109
+ const owned = await loadOwnedProject({
1110
+ github,
1111
+ auth,
1112
+ fleet,
1113
+ c: loose(c)
1114
+ });
1115
+ if ("response" in owned) return owned.response;
1116
+ const { orgId, userId, project } = owned;
1117
+ let body;
1118
+ try {
1119
+ body = await c.req.json();
1120
+ } catch {
1121
+ return c.json({ error: "Invalid JSON body" }, 400);
1122
+ }
1123
+ if (!isValidGitRef$1(body.branch)) return c.json({ error: "Invalid branch" }, 400);
1124
+ const branch = body.branch;
1125
+ const sessionWorkspace = await resolveSessionWorkspace(github, project.id, userId, body.sessionId);
1126
+ if (!sessionWorkspace) return c.json({ error: "Invalid sessionId" }, 400);
1127
+ const { workdir, sandboxBinding } = sessionWorkspace;
1128
+ try {
1129
+ return await withSessionOperationLock(sessionWorkspace.session.sessionId, async () => {
1130
+ const sandbox = await resolveProjectSandbox({
1131
+ fleet,
1132
+ sandboxRow: sandboxBinding
1133
+ });
1134
+ const access = await github.versionControl.getRepositoryAccess({
1135
+ orgId,
1136
+ repositoryId: project.repository.id
1137
+ });
1138
+ if (!access.authorization) throw new Error("Repository access did not include a bearer token.");
1139
+ await pushBranch(sandbox, workdir, branch, access.authorization.token, project.repository.slug);
1140
+ await emitAudit?.({
1141
+ context: loose(c),
1142
+ input: {
1143
+ action: "factory.git.push",
1144
+ factoryProjectId: project.factoryProjectId,
1145
+ projectRepositoryId: project.id,
1146
+ targets: [{
1147
+ type: "branch",
1148
+ id: branch
1149
+ }],
1150
+ metadata: {
1151
+ branch,
1152
+ sessionId: sessionWorkspace.session.sessionId
1153
+ }
1154
+ }
1155
+ });
1156
+ return c.json({
1157
+ pushed: true,
1158
+ branch
1159
+ });
1160
+ });
1161
+ } catch (err) {
1162
+ return gitErrorResponse(loose(c), err);
1163
+ }
1164
+ }
1165
+ }),
1166
+ registerApiRoute("/web/github/projects/:id/pr", {
1167
+ method: "POST",
1168
+ requiresAuth: false,
1169
+ handler: async (c) => {
1170
+ const owned = await loadOwnedProject({
1171
+ github,
1172
+ auth,
1173
+ fleet,
1174
+ c: loose(c)
1175
+ });
1176
+ if ("response" in owned) return owned.response;
1177
+ const { orgId, userId, project } = owned;
1178
+ let body;
1179
+ try {
1180
+ body = await c.req.json();
1181
+ } catch {
1182
+ return c.json({ error: "Invalid JSON body" }, 400);
1183
+ }
1184
+ if (!isValidGitRef$1(body.branch)) return c.json({ error: "Invalid branch" }, 400);
1185
+ const base = body.base === void 0 ? project.defaultBranch : body.base;
1186
+ if (!isValidGitRef$1(base)) return c.json({ error: "Invalid base" }, 400);
1187
+ if (typeof body.title !== "string" || body.title.trim().length === 0 || body.title.length > 256) return c.json({ error: "Invalid title" }, 400);
1188
+ if (body.body !== void 0 && (typeof body.body !== "string" || body.body.length > 65536)) return c.json({ error: "Invalid body" }, 400);
1189
+ const head = body.branch;
1190
+ const title = body.title;
1191
+ const prBody = body.body;
1192
+ const sessionWorkspace = await resolveSessionWorkspace(github, project.id, userId, body.sessionId);
1193
+ if (!sessionWorkspace) return c.json({ error: "Invalid sessionId" }, 400);
1194
+ try {
1195
+ return await withSessionOperationLock(sessionWorkspace.session.sessionId, async () => {
1196
+ const result = await github.versionControl.createPullRequest({
1197
+ connection: {
1198
+ type: "app-installation",
1199
+ installationId: Number(project.installation.externalId)
1200
+ },
1201
+ sourceId: project.repository.slug,
1202
+ baseBranch: base,
1203
+ headBranch: head,
1204
+ title,
1205
+ body: prBody,
1206
+ actingUserId: userId
1207
+ });
1208
+ await emitAudit?.({
1209
+ context: loose(c),
1210
+ input: {
1211
+ action: "factory.git.pr_opened",
1212
+ factoryProjectId: project.factoryProjectId,
1213
+ projectRepositoryId: project.id,
1214
+ targets: [{
1215
+ type: "pull_request",
1216
+ id: result.url,
1217
+ name: title
1218
+ }],
1219
+ metadata: {
1220
+ branch: head,
1221
+ base,
1222
+ url: result.url
1223
+ }
1224
+ }
1225
+ });
1226
+ const pullRequestNumber = pullRequestNumberFromUrl(result.url, project.repository.slug);
1227
+ if (pullRequestNumber) {
1228
+ const sessionId = sessionWorkspace.session.sessionId;
1229
+ await subscribeToPullRequest({
1230
+ orgId,
1231
+ installationExternalId: project.installation.externalId,
1232
+ projectRepositoryId: project.id,
1233
+ repositoryExternalId: project.repository.externalId,
1234
+ repositorySlug: project.repository.slug,
1235
+ changeRequestId: pullRequestNumber.toString(),
1236
+ sessionId,
1237
+ ownerId: userId,
1238
+ resourceId: sessionId,
1239
+ threadId: sessionId,
1240
+ source: "factory-pr-create",
1241
+ subscribedByUserId: userId
1242
+ }, github.integrationStorage).catch((error) => {
1243
+ console.warn(`[GitHub] Pull request ${result.url} was created but automatic subscription failed.`, error);
1244
+ });
1245
+ }
1246
+ return c.json({ url: result.url });
1247
+ });
1248
+ } catch (err) {
1249
+ return c.json({
1250
+ error: "github_pr_create_failed",
1251
+ message: err instanceof Error ? err.message : String(err)
1252
+ }, 502);
1253
+ }
1254
+ }
1255
+ }),
1256
+ registerApiRoute("/web/github/projects/:id/sandbox", {
1257
+ method: "DELETE",
1258
+ requiresAuth: false,
1259
+ handler: async (c) => {
1260
+ const owned = await loadOwnedProject({
1261
+ github,
1262
+ auth,
1263
+ fleet,
1264
+ c: loose(c)
1265
+ });
1266
+ if ("response" in owned) return owned.response;
1267
+ const { sandboxRow } = owned;
1268
+ if (!sandboxRow.sandboxId) return c.json({ tornDown: false });
1269
+ try {
1270
+ return await withSessionOperationLock(`sandbox:${sandboxRow.id}`, async () => {
1271
+ const sandbox = await fleet.reattachSandbox(sandboxRow.sandboxId);
1272
+ await teardownProjectSandbox({
1273
+ fleet,
1274
+ row: sandboxRow,
1275
+ storage: github.sourceControlStorage.sandboxes,
1276
+ sandbox
1277
+ });
1278
+ return c.json({ tornDown: true });
1279
+ });
1280
+ } catch (err) {
1281
+ return gitErrorResponse(loose(c), err);
1282
+ }
1283
+ }
1284
+ })
1285
+ ];
1286
+ }
1287
+ /** Resolve the materialized workspace owned by a Factory session. */
1845
1288
  async function resolveSessionWorkspace(github, projectId, userId, sessionId) {
1846
- if (typeof sessionId !== "string") {
1847
- return void 0;
1848
- }
1849
- const session = await github.sourceControlStorage.sessions.getBySessionId(sessionId);
1850
- if (session?.projectRepositoryId !== projectId || session.userId !== userId || !session.sandboxId || !session.sandboxWorkdir) {
1851
- return void 0;
1852
- }
1853
- return {
1854
- session,
1855
- workdir: session.sandboxWorkdir,
1856
- sandboxBinding: {
1857
- id: session.id,
1858
- projectRepositoryId: session.projectRepositoryId,
1859
- userId: session.userId,
1860
- sandboxId: session.sandboxId,
1861
- sandboxWorkdir: session.sandboxWorkdir,
1862
- materializedAt: session.materializedAt,
1863
- createdAt: session.createdAt
1864
- }
1865
- };
1866
- }
1867
- export {
1868
- buildGithubRoutes
1869
- };
1289
+ if (typeof sessionId !== "string") return;
1290
+ const session = await github.sourceControlStorage.sessions.getBySessionId(sessionId);
1291
+ if (session?.projectRepositoryId !== projectId || session.userId !== userId || !session.sandboxId || !session.sandboxWorkdir) return;
1292
+ return {
1293
+ session,
1294
+ workdir: session.sandboxWorkdir,
1295
+ sandboxBinding: {
1296
+ id: session.id,
1297
+ projectRepositoryId: session.projectRepositoryId,
1298
+ userId: session.userId,
1299
+ sandboxId: session.sandboxId,
1300
+ sandboxWorkdir: session.sandboxWorkdir,
1301
+ materializedAt: session.materializedAt,
1302
+ createdAt: session.createdAt
1303
+ }
1304
+ };
1305
+ }
1306
+ //#endregion
1307
+ export { buildGithubRoutes };
1308
+
1870
1309
  //# sourceMappingURL=routes.js.map