cyrus-edge-worker 0.2.37 → 0.2.39

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.
@@ -10,6 +10,7 @@ import { CLIIssueTrackerService, CLIRPCServer, createLogger, DEFAULT_PROXY_URL,
10
10
  import { CursorRunner } from "cyrus-cursor-runner";
11
11
  import { GeminiRunner } from "cyrus-gemini-runner";
12
12
  import { extractCommentAuthor, extractCommentBody, extractCommentId, extractCommentUrl, extractPRBaseBranchRef, extractPRBranchRef, extractPRNumber, extractPRTitle, extractRepoFullName, extractRepoName, extractRepoOwner, extractSessionKey, GitHubCommentService, GitHubEventTransport, isCommentOnPullRequest, isIssueCommentPayload, isPullRequestReviewCommentPayload, isPullRequestReviewPayload, stripMention, } from "cyrus-github-event-transport";
13
+ import { extractDiscussionId, extractSessionKey as extractGitLabSessionKey, extractMRBaseBranchRef, extractMRBranchRef, extractMRIid, extractMRTitle, extractNoteAuthor, extractNoteBody, extractNoteId, extractNoteUrl, extractProjectId, extractProjectPath, GitLabCommentService, GitLabEventTransport, isNoteOnMergeRequest, stripMention as stripGitLabMention, } from "cyrus-gitlab-event-transport";
13
14
  import { LinearEventTransport, LinearIssueTrackerService, } from "cyrus-linear-event-transport";
14
15
  import { createCyrusToolsServer, } from "cyrus-mcp-tools";
15
16
  import { SlackEventTransport, } from "cyrus-slack-event-transport";
@@ -24,7 +25,7 @@ import { GitService } from "./GitService.js";
24
25
  import { GlobalSessionRegistry } from "./GlobalSessionRegistry.js";
25
26
  import { McpConfigService } from "./McpConfigService.js";
26
27
  import { PromptBuilder } from "./PromptBuilder.js";
27
- import { ProcedureAnalyzer, } from "./procedures/index.js";
28
+ import { applyPlatformSubroutines, ProcedureAnalyzer, } from "./procedures/index.js";
28
29
  import { RepositoryRouter, } from "./RepositoryRouter.js";
29
30
  import { RunnerConfigBuilder } from "./RunnerConfigBuilder.js";
30
31
  import { RunnerSelectionService } from "./RunnerSelectionService.js";
@@ -48,9 +49,11 @@ export class EdgeWorker extends EventEmitter {
48
49
  issueTrackers = new Map(); // one issue tracker per Linear workspace (keyed by linearWorkspaceId)
49
50
  linearEventTransport = null; // Single event transport for webhook delivery
50
51
  gitHubEventTransport = null; // GitHub event transport for forwarded GitHub webhooks
52
+ gitLabEventTransport = null; // GitLab event transport for forwarded GitLab webhooks
51
53
  slackEventTransport = null;
52
54
  chatSessionHandler = null;
53
55
  gitHubCommentService; // Service for posting comments back to GitHub PRs
56
+ gitLabCommentService; // Service for posting comments back to GitLab MRs
54
57
  cliRPCServer = null; // CLI RPC server for CLI platform mode
55
58
  configUpdater = null; // Single config updater for configuration updates
56
59
  persistenceManager;
@@ -95,6 +98,8 @@ export class EdgeWorker extends EventEmitter {
95
98
  this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
96
99
  // Initialize GitHub comment service for posting replies to GitHub PRs
97
100
  this.gitHubCommentService = new GitHubCommentService();
101
+ // Initialize GitLab comment service for posting replies to GitLab MRs
102
+ this.gitLabCommentService = new GitLabCommentService();
98
103
  // Initialize global session registry (centralized session storage)
99
104
  this.globalSessionRegistry = new GlobalSessionRegistry();
100
105
  // Initialize procedure router for fast classification
@@ -288,6 +293,8 @@ export class EdgeWorker extends EventEmitter {
288
293
  await this.removeDeletedRepositories(changes.removed);
289
294
  await this.updateModifiedRepositories(changes.modified);
290
295
  await this.addNewRepositories(changes.added);
296
+ // Detect and apply workspace token changes before overwriting config
297
+ this.updateLinearWorkspaceTokens(changes.newConfig);
291
298
  const prevDefaultRunner = this.config.defaultRunner;
292
299
  this.config = changes.newConfig;
293
300
  this.configManager.setConfig(changes.newConfig);
@@ -321,50 +328,39 @@ export class EdgeWorker extends EventEmitter {
321
328
  * Initialize and register components (routes) before server starts
322
329
  */
323
330
  async initializeComponents() {
324
- // Get the first active repository for configuration
325
- const firstRepo = Array.from(this.repositories.values())[0];
326
- if (!firstRepo) {
327
- throw new Error("No active repositories configured");
328
- }
329
- // Platform-specific initialization
331
+ // 1. Platform-specific initialization
330
332
  if (this.config.platform === "cli") {
331
- // CLI mode: Create and register CLIRPCServer
332
- const firstIssueTracker = this.issueTrackers.get(requireLinearWorkspaceId(firstRepo));
333
- if (!firstIssueTracker) {
334
- throw new Error("Issue tracker not found for first repository");
335
- }
336
- // Type guard to ensure it's a CLIIssueTrackerService
337
- if (!(firstIssueTracker instanceof CLIIssueTrackerService)) {
338
- throw new Error("CLI platform requires CLIIssueTrackerService but found different implementation");
333
+ // CLI mode: find any available CLIIssueTrackerService
334
+ const firstCliTracker = Array.from(this.issueTrackers.values()).find((tracker) => tracker instanceof CLIIssueTrackerService);
335
+ if (firstCliTracker) {
336
+ this.cliRPCServer = new CLIRPCServer({
337
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
338
+ issueTracker: firstCliTracker,
339
+ version: "1.0.0",
340
+ });
341
+ // Register the /cli/rpc endpoint
342
+ this.cliRPCServer.register();
343
+ this.logger.info("✅ CLI RPC server registered");
344
+ this.logger.info(" RPC endpoint: /cli/rpc");
345
+ // Create CLI event transport and register listener
346
+ const cliEventTransport = firstCliTracker.createEventTransport({
347
+ platform: "cli",
348
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
349
+ });
350
+ // Listen for webhook events
351
+ cliEventTransport.on("event", (event) => {
352
+ const repos = Array.from(this.repositories.values());
353
+ this.handleWebhook(event, repos);
354
+ });
355
+ // Listen for errors
356
+ cliEventTransport.on("error", (error) => {
357
+ this.handleError(error);
358
+ });
359
+ // Register the CLI event transport endpoints
360
+ cliEventTransport.register();
361
+ this.logger.info("✅ CLI event transport registered");
362
+ this.logger.info(" Event listener: listening for AgentSessionCreated events");
339
363
  }
340
- this.cliRPCServer = new CLIRPCServer({
341
- fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
342
- issueTracker: firstIssueTracker,
343
- version: "1.0.0",
344
- });
345
- // Register the /cli/rpc endpoint
346
- this.cliRPCServer.register();
347
- this.logger.info("✅ CLI RPC server registered");
348
- this.logger.info(" RPC endpoint: /cli/rpc");
349
- // Create CLI event transport and register listener
350
- const cliEventTransport = firstIssueTracker.createEventTransport({
351
- platform: "cli",
352
- fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
353
- });
354
- // Listen for webhook events (same pattern as Linear mode)
355
- cliEventTransport.on("event", (event) => {
356
- // Get all active repositories for webhook handling
357
- const repos = Array.from(this.repositories.values());
358
- this.handleWebhook(event, repos);
359
- });
360
- // Listen for errors
361
- cliEventTransport.on("error", (error) => {
362
- this.handleError(error);
363
- });
364
- // Register the CLI event transport endpoints
365
- cliEventTransport.register();
366
- this.logger.info("✅ CLI event transport registered");
367
- this.logger.info(" Event listener: listening for AgentSessionCreated events");
368
364
  }
369
365
  else {
370
366
  // Linear mode: Create and register LinearEventTransport
@@ -381,7 +377,6 @@ export class EdgeWorker extends EventEmitter {
381
377
  });
382
378
  // Listen for legacy webhook events (deprecated, kept for backward compatibility)
383
379
  this.linearEventTransport.on("event", (event) => {
384
- // Get all active repositories for webhook handling
385
380
  const repos = Array.from(this.repositories.values());
386
381
  this.handleWebhook(event, repos);
387
382
  });
@@ -398,10 +393,11 @@ export class EdgeWorker extends EventEmitter {
398
393
  this.logger.info(`✅ Linear event transport registered (${verificationMode} mode)`);
399
394
  this.logger.info(` Webhook endpoint: ${this.sharedApplicationServer.getWebhookUrl()}`);
400
395
  }
401
- // 2. Register GitHub event transport (for forwarded GitHub webhooks from CYHOST)
402
- // This is registered regardless of platform mode since GitHub webhooks can come from CYHOST
396
+ // 2. Register GitHub and Slack event transports unconditionally
397
+ // These don't require repositories and must be available during onboarding
398
+ // for webhook URL verification to succeed.
403
399
  this.registerGitHubEventTransport();
404
- // 2b. Register Slack event transport (for forwarded Slack webhooks from CYHOST)
400
+ this.registerGitLabEventTransport();
405
401
  this.registerSlackEventTransport();
406
402
  // 3. Create and register ConfigUpdater (both platforms)
407
403
  this.configUpdater = new ConfigUpdater(this.sharedApplicationServer.getFastifyInstance(), this.cyrusHome, process.env.CYRUS_API_KEY || "");
@@ -486,6 +482,43 @@ export class EdgeWorker extends EventEmitter {
486
482
  this.logger.info(`GitHub event transport registered (${verificationMode} mode)`);
487
483
  this.logger.info("Webhook endpoint: POST /github-webhook");
488
484
  }
485
+ /**
486
+ * Register the GitLab event transport for receiving forwarded GitLab webhooks.
487
+ * This creates a /gitlab-webhook endpoint that handles note events on merge requests.
488
+ */
489
+ registerGitLabEventTransport() {
490
+ const isExternalHost = process.env.CYRUS_HOST_EXTERNAL?.toLowerCase().trim() === "true";
491
+ const hasGitlabWebhookSecret = process.env.GITLAB_WEBHOOK_SECRET != null &&
492
+ process.env.GITLAB_WEBHOOK_SECRET !== "";
493
+ const useSignatureVerification = isExternalHost && hasGitlabWebhookSecret;
494
+ const verificationMode = useSignatureVerification ? "signature" : "proxy";
495
+ const secret = useSignatureVerification
496
+ ? process.env.GITLAB_WEBHOOK_SECRET
497
+ : process.env.CYRUS_API_KEY || "";
498
+ this.gitLabEventTransport = new GitLabEventTransport({
499
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
500
+ verificationMode,
501
+ secret,
502
+ });
503
+ // Listen for legacy GitLab webhook events
504
+ this.gitLabEventTransport.on("event", (event) => {
505
+ this.handleGitLabWebhook(event).catch((error) => {
506
+ this.logger.error("Failed to handle GitLab webhook", error instanceof Error ? error : new Error(String(error)));
507
+ });
508
+ });
509
+ // Listen for unified internal messages (new message bus)
510
+ this.gitLabEventTransport.on("message", (message) => {
511
+ this.handleMessage(message);
512
+ });
513
+ // Listen for errors
514
+ this.gitLabEventTransport.on("error", (error) => {
515
+ this.handleError(error);
516
+ });
517
+ // Register the /gitlab-webhook endpoint
518
+ this.gitLabEventTransport.register();
519
+ this.logger.info(`GitLab event transport registered (${verificationMode} mode)`);
520
+ this.logger.info("Webhook endpoint: POST /gitlab-webhook");
521
+ }
489
522
  /**
490
523
  * Register the Slack event transport for receiving forwarded Slack webhooks from CYHOST.
491
524
  * This creates a /slack-webhook endpoint that handles @mention events from Slack.
@@ -1009,6 +1042,373 @@ ${taskSection}`;
1009
1042
  this.logger.error("Failed to post GitHub reply", error instanceof Error ? error : new Error(String(error)));
1010
1043
  }
1011
1044
  }
1045
+ /**
1046
+ * Handle an incoming GitLab webhook event (note on a merge request).
1047
+ * Mirrors the GitHub webhook handler but uses GitLab-specific utilities.
1048
+ */
1049
+ async handleGitLabWebhook(event) {
1050
+ this.activeWebhookCount++;
1051
+ try {
1052
+ // Only handle notes on merge requests
1053
+ if (!isNoteOnMergeRequest(event)) {
1054
+ this.logger.debug("Ignoring GitLab event: not a note on a merge request");
1055
+ return;
1056
+ }
1057
+ const projectPath = extractProjectPath(event);
1058
+ const mrIid = extractMRIid(event);
1059
+ const noteBody = extractNoteBody(event);
1060
+ const noteAuthor = extractNoteAuthor(event);
1061
+ const mrTitle = extractMRTitle(event);
1062
+ const sessionKey = extractGitLabSessionKey(event);
1063
+ // Skip comments from the bot itself to prevent infinite loops
1064
+ const botUsername = process.env.GITLAB_BOT_USERNAME;
1065
+ if (botUsername && noteAuthor === botUsername) {
1066
+ this.logger.debug(`Ignoring note from bot user @${botUsername} on ${projectPath}!${mrIid}`);
1067
+ return;
1068
+ }
1069
+ // Only trigger on notes that mention the bot (when configured)
1070
+ if (botUsername && !noteBody.includes(`@${botUsername}`)) {
1071
+ this.logger.debug(`Ignoring note without @${botUsername} mention on ${projectPath}!${mrIid}`);
1072
+ return;
1073
+ }
1074
+ this.logger.info(`Processing GitLab webhook: ${projectPath}!${mrIid} by @${noteAuthor}`);
1075
+ // Add "eyes" emoji reaction to acknowledge receipt
1076
+ const reactionToken = event.accessToken || process.env.GITLAB_ACCESS_TOKEN;
1077
+ const noteId = extractNoteId(event);
1078
+ const projectId = extractProjectId(event);
1079
+ if (reactionToken && noteId && projectId && mrIid) {
1080
+ this.gitLabCommentService
1081
+ .addAwardEmoji({
1082
+ token: reactionToken,
1083
+ projectId,
1084
+ mrIid,
1085
+ noteId,
1086
+ name: "eyes",
1087
+ })
1088
+ .catch((err) => {
1089
+ this.logger.warn(`Failed to add GitLab emoji reaction: ${err instanceof Error ? err.message : err}`);
1090
+ });
1091
+ }
1092
+ // Find the repository configuration that matches this GitLab project
1093
+ const repository = this.findRepositoryByGitLabUrl(projectPath);
1094
+ if (!repository) {
1095
+ this.logger.warn(`No repository configured for GitLab project: ${projectPath}`);
1096
+ return;
1097
+ }
1098
+ const agentSessionManager = this.agentSessionManager;
1099
+ // Branch refs are available directly from the MR payload
1100
+ const branchRef = extractMRBranchRef(event);
1101
+ const baseBranchRef = extractMRBaseBranchRef(event);
1102
+ if (!branchRef || !mrIid) {
1103
+ this.logger.error(`Could not determine branch or MR iid for ${projectPath}!${mrIid}`);
1104
+ return;
1105
+ }
1106
+ // Strip the bot mention to get the task instructions
1107
+ const mentionHandle = botUsername ? `@${botUsername}` : "@cyrusagent";
1108
+ const taskInstructions = stripGitLabMention(noteBody, mentionHandle);
1109
+ // Check for an existing multi-repo session that includes this repository
1110
+ let workspace = null;
1111
+ const multiRepoSession = agentSessionManager.getActiveMultiRepoSessionForRepository(repository.id);
1112
+ if (multiRepoSession) {
1113
+ const subWorktreePath = multiRepoSession.workspace.repoPaths?.[repository.id];
1114
+ if (subWorktreePath) {
1115
+ workspace = {
1116
+ path: subWorktreePath,
1117
+ isGitWorktree: true,
1118
+ };
1119
+ this.logger.info(`Resolved multi-repo sub-worktree for ${repository.name}: ${subWorktreePath}`);
1120
+ }
1121
+ else {
1122
+ this.logger.warn(`No sub-worktree found for repo ${repository.name} in multi-repo session ${multiRepoSession.id}, falling back to root workspace`);
1123
+ workspace = {
1124
+ path: multiRepoSession.workspace.path,
1125
+ isGitWorktree: true,
1126
+ };
1127
+ }
1128
+ }
1129
+ else {
1130
+ // Single-repo or no existing session: create workspace
1131
+ workspace = await this.createGitLabWorkspace(repository, branchRef, mrIid);
1132
+ }
1133
+ if (!workspace) {
1134
+ this.logger.error(`Failed to create workspace for ${projectPath}!${mrIid}`);
1135
+ return;
1136
+ }
1137
+ this.logger.info(`GitLab workspace created at: ${workspace.path}`);
1138
+ // Check if another active session is already using this branch/workspace
1139
+ const existingSessions = agentSessionManager.getActiveSessionsByBranchName(branchRef);
1140
+ const firstExisting = existingSessions[0];
1141
+ if (firstExisting) {
1142
+ this.logger.warn(`Reusing workspace from active session ${firstExisting.id} — concurrent writes possible`);
1143
+ }
1144
+ // Create a synthetic session for this GitLab MR note
1145
+ const issueMinimal = {
1146
+ id: sessionKey,
1147
+ identifier: `${projectPath}!${mrIid}`,
1148
+ title: mrTitle || `MR !${mrIid}`,
1149
+ branchName: branchRef,
1150
+ };
1151
+ // Create an internal agent session (no Linear session for GitLab)
1152
+ const gitlabSessionId = `gitlab-${Date.now()}`;
1153
+ agentSessionManager.createCyrusAgentSession(gitlabSessionId, sessionKey, issueMinimal, workspace, "gitlab", // Don't stream activities to Linear for GitLab sources
1154
+ [
1155
+ {
1156
+ repositoryId: repository.id,
1157
+ branchName: branchRef,
1158
+ baseBranchName: baseBranchRef ?? repository.baseBranch,
1159
+ },
1160
+ ]);
1161
+ // Register session-to-repo mapping and activity sink
1162
+ this.sessionRepositories.set(gitlabSessionId, repository.id);
1163
+ const activitySink = this.activitySinks.get(repository.id);
1164
+ if (activitySink) {
1165
+ agentSessionManager.setActivitySink(gitlabSessionId, activitySink);
1166
+ }
1167
+ const session = agentSessionManager.getSession(gitlabSessionId);
1168
+ if (!session) {
1169
+ this.logger.error(`Failed to create session for GitLab webhook on ${projectPath}!${mrIid}`);
1170
+ return;
1171
+ }
1172
+ // Initialize procedure metadata
1173
+ if (!session.metadata) {
1174
+ session.metadata = {};
1175
+ }
1176
+ // Store GitLab-specific metadata for reply posting
1177
+ // Reuse commentId for note ID (serves the same purpose across platforms)
1178
+ session.metadata.commentId = String(noteId);
1179
+ // Build the system prompt for this GitLab MR session
1180
+ // TODO: Use buildGitLabChangeRequestSystemPrompt for merge_request approval events
1181
+ const isMergeRequestEvent = event.eventType === "merge_request";
1182
+ const systemPrompt = isMergeRequestEvent
1183
+ ? this.buildGitLabChangeRequestSystemPrompt(event, branchRef, taskInstructions)
1184
+ : this.buildGitLabSystemPrompt(event, branchRef, taskInstructions);
1185
+ // Build allowed tools and directories
1186
+ // Exclude Slack MCP tools from GitLab sessions
1187
+ const allowedTools = this.buildAllowedTools(repository).filter((t) => t !== "mcp__slack");
1188
+ const disallowedTools = this.buildDisallowedTools(repository);
1189
+ const allowedDirectories = [repository.repositoryPath];
1190
+ // Create agent runner using the standard config builder
1191
+ const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, gitlabSessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
1192
+ undefined, // labels
1193
+ undefined, // issueDescription
1194
+ 200, // maxTurns
1195
+ false, // singleTurn
1196
+ undefined, // disallowAllTools
1197
+ { excludeSlackMcp: true });
1198
+ const runner = this.createRunnerForType(runnerType, runnerConfig);
1199
+ // Store the runner in the session manager
1200
+ agentSessionManager.addAgentRunner(gitlabSessionId, runner);
1201
+ // Save persisted state
1202
+ await this.savePersistedState();
1203
+ this.emit("session:started", sessionKey, issueMinimal, repository.id);
1204
+ this.logger.info(`Starting ${runnerType} runner for GitLab MR ${projectPath}!${mrIid}`);
1205
+ // Start the session and handle completion
1206
+ try {
1207
+ const sessionInfo = await runner.start(taskInstructions);
1208
+ this.logger.info(`GitLab session started: ${sessionInfo.sessionId}`);
1209
+ // When session completes, post the reply back to GitLab
1210
+ await this.postGitLabReply(event, runner, repository);
1211
+ }
1212
+ catch (error) {
1213
+ this.logger.error(`GitLab session error for ${projectPath}!${mrIid}`, error instanceof Error ? error : new Error(String(error)));
1214
+ }
1215
+ finally {
1216
+ await this.savePersistedState();
1217
+ }
1218
+ }
1219
+ catch (error) {
1220
+ this.logger.error("Failed to process GitLab webhook", error instanceof Error ? error : new Error(String(error)));
1221
+ }
1222
+ finally {
1223
+ this.activeWebhookCount--;
1224
+ }
1225
+ }
1226
+ /**
1227
+ * Find a repository configuration that matches a GitLab project URL.
1228
+ * Matches against the gitlabUrl field in repository config.
1229
+ */
1230
+ findRepositoryByGitLabUrl(projectPath) {
1231
+ for (const repo of this.repositories.values()) {
1232
+ if (!repo.gitlabUrl)
1233
+ continue;
1234
+ if (repo.gitlabUrl.includes(projectPath) ||
1235
+ repo.gitlabUrl.endsWith(`/${projectPath}`)) {
1236
+ return repo;
1237
+ }
1238
+ }
1239
+ return null;
1240
+ }
1241
+ /**
1242
+ * Create a git worktree for a GitLab MR branch.
1243
+ * If the worktree already exists for this branch, reuse it.
1244
+ */
1245
+ async createGitLabWorkspace(repository, branchRef, mrIid) {
1246
+ try {
1247
+ // Create a synthetic issue-like object for the git service
1248
+ const syntheticIssue = {
1249
+ id: `gitlab-mr-${mrIid}`,
1250
+ identifier: `MR-${mrIid}`,
1251
+ title: `MR !${mrIid}`,
1252
+ description: null,
1253
+ url: "",
1254
+ branchName: branchRef,
1255
+ assigneeId: null,
1256
+ stateId: null,
1257
+ teamId: null,
1258
+ labelIds: [],
1259
+ priority: 0,
1260
+ createdAt: new Date(),
1261
+ updatedAt: new Date(),
1262
+ archivedAt: null,
1263
+ state: Promise.resolve(undefined),
1264
+ assignee: Promise.resolve(undefined),
1265
+ team: Promise.resolve(undefined),
1266
+ parent: Promise.resolve(undefined),
1267
+ project: Promise.resolve(undefined),
1268
+ labels: () => Promise.resolve({ nodes: [] }),
1269
+ comments: () => Promise.resolve({ nodes: [] }),
1270
+ attachments: () => Promise.resolve({ nodes: [] }),
1271
+ children: () => Promise.resolve({ nodes: [] }),
1272
+ inverseRelations: () => Promise.resolve({ nodes: [] }),
1273
+ update: () => Promise.resolve({
1274
+ success: true,
1275
+ issue: undefined,
1276
+ lastSyncId: 0,
1277
+ }),
1278
+ };
1279
+ return await this.gitService.createGitWorktree(syntheticIssue, [
1280
+ repository,
1281
+ ]);
1282
+ }
1283
+ catch (error) {
1284
+ this.logger.error(`Failed to create GitLab workspace for MR !${mrIid}`, error instanceof Error ? error : new Error(String(error)));
1285
+ return null;
1286
+ }
1287
+ }
1288
+ /**
1289
+ * Build a system prompt for a GitLab MR note session.
1290
+ */
1291
+ buildGitLabSystemPrompt(event, branchRef, taskInstructions) {
1292
+ const projectPath = extractProjectPath(event);
1293
+ const mrIid = extractMRIid(event);
1294
+ const mrTitle = extractMRTitle(event);
1295
+ const noteAuthor = extractNoteAuthor(event);
1296
+ const noteUrl = extractNoteUrl(event);
1297
+ return `You are working on a GitLab Merge Request.
1298
+
1299
+ ## Context
1300
+ - **Project**: ${projectPath}
1301
+ - **MR**: !${mrIid} - ${mrTitle || "Untitled"}
1302
+ - **Branch**: ${branchRef}
1303
+ - **Requested by**: @${noteAuthor}
1304
+ - **Note URL**: ${noteUrl}
1305
+
1306
+ ## Task
1307
+ ${taskInstructions}
1308
+
1309
+ ## Instructions
1310
+ - You are already checked out on the MR branch \`${branchRef}\`
1311
+ - Make changes directly to the code on this branch
1312
+ - After making changes, commit and push them to the branch
1313
+ - Use \`glab\` CLI commands for GitLab-specific operations
1314
+ - Be concise in your responses as they will be posted back to the GitLab MR`;
1315
+ }
1316
+ /**
1317
+ * Build a system prompt for a GitLab MR change request session.
1318
+ */
1319
+ buildGitLabChangeRequestSystemPrompt(event, branchRef, reviewBody) {
1320
+ const projectPath = extractProjectPath(event);
1321
+ const mrIid = extractMRIid(event);
1322
+ const mrTitle = extractMRTitle(event);
1323
+ const noteAuthor = extractNoteAuthor(event);
1324
+ const noteUrl = extractNoteUrl(event);
1325
+ const hasReviewBody = reviewBody.trim().length > 0;
1326
+ const taskSection = hasReviewBody
1327
+ ? `## Reviewer Feedback
1328
+ ${reviewBody}
1329
+
1330
+ ## Instructions
1331
+ - Read the MR diff and the reviewer's feedback above to understand all requested changes
1332
+ - You are already checked out on the MR branch \`${branchRef}\`
1333
+ - Address all the reviewer's feedback and make the necessary changes
1334
+ - After making changes, commit and push them to the branch
1335
+ - Respond with a concise summary of the changes you made`
1336
+ : `## Instructions
1337
+ - The reviewer has requested changes but did not leave a summary comment
1338
+ - Use \`glab mr view ${mrIid}\` and \`glab mr diff ${mrIid}\` to review the MR context
1339
+ - You are already checked out on the MR branch \`${branchRef}\`
1340
+ - Address all the reviewer's feedback and make the necessary changes
1341
+ - After making changes, commit and push them to the branch
1342
+ - Respond with a concise summary of the changes you made`;
1343
+ return `You are working on a GitLab Merge Request that has received a change request review.
1344
+
1345
+ ## Context
1346
+ - **Project**: ${projectPath}
1347
+ - **MR**: !${mrIid} - ${mrTitle || "Untitled"}
1348
+ - **Branch**: ${branchRef}
1349
+ - **Reviewer**: @${noteAuthor}
1350
+ - **Note URL**: ${noteUrl}
1351
+
1352
+ ${taskSection}`;
1353
+ }
1354
+ /**
1355
+ * Post a reply back to the GitLab MR after the session completes.
1356
+ */
1357
+ async postGitLabReply(event, runner, _repository) {
1358
+ try {
1359
+ // Get the last assistant message from the runner as the summary
1360
+ const messages = runner.getMessages();
1361
+ const lastAssistantMessage = [...messages]
1362
+ .reverse()
1363
+ .find((m) => m.type === "assistant");
1364
+ let summary = "Task completed. Please review the changes on this branch.";
1365
+ if (lastAssistantMessage &&
1366
+ lastAssistantMessage.type === "assistant" &&
1367
+ "message" in lastAssistantMessage) {
1368
+ const msg = lastAssistantMessage;
1369
+ const textBlock = msg.message.content?.find((block) => block.type === "text" && block.text);
1370
+ if (textBlock?.text) {
1371
+ summary = textBlock.text;
1372
+ }
1373
+ }
1374
+ const projectId = extractProjectId(event);
1375
+ const mrIid = extractMRIid(event);
1376
+ const discussionId = extractDiscussionId(event);
1377
+ if (!mrIid) {
1378
+ this.logger.warn("Cannot post GitLab reply: no MR iid");
1379
+ return;
1380
+ }
1381
+ const token = event.accessToken || process.env.GITLAB_ACCESS_TOKEN;
1382
+ if (!token) {
1383
+ this.logger.warn("Cannot post GitLab reply: no access token or GITLAB_ACCESS_TOKEN configured");
1384
+ this.logger.debug(`Would have posted reply to ${extractProjectPath(event)}!${mrIid}: ${summary}`);
1385
+ return;
1386
+ }
1387
+ if (discussionId) {
1388
+ // Reply to the specific discussion thread
1389
+ await this.gitLabCommentService.postDiscussionReply({
1390
+ token,
1391
+ projectId,
1392
+ mrIid,
1393
+ discussionId,
1394
+ body: summary,
1395
+ });
1396
+ }
1397
+ else {
1398
+ // Post as a top-level MR note
1399
+ await this.gitLabCommentService.postMRNote({
1400
+ token,
1401
+ projectId,
1402
+ mrIid,
1403
+ body: summary,
1404
+ });
1405
+ }
1406
+ this.logger.info(`Posted GitLab reply to ${extractProjectPath(event)}!${mrIid}`);
1407
+ }
1408
+ catch (error) {
1409
+ this.logger.error("Failed to post GitLab reply", error instanceof Error ? error : new Error(String(error)));
1410
+ }
1411
+ }
1012
1412
  /**
1013
1413
  * Compute the current status of the Cyrus process
1014
1414
  * @returns "idle" if the process can be safely restarted, "busy" if work is in progress
@@ -1228,6 +1628,43 @@ ${taskSection}`;
1228
1628
  this.logger.error(`Failed to re-run verifications:`, error);
1229
1629
  }
1230
1630
  }
1631
+ /**
1632
+ * Detect workspace token changes and update all dependent services.
1633
+ *
1634
+ * When an OAuth token is refreshed (at least once per day), the new token is
1635
+ * persisted to config.json which triggers the file watcher. This method
1636
+ * compares the previous in-memory tokens against the new config and calls
1637
+ * `setAccessToken()` on any affected `LinearIssueTrackerService` instances,
1638
+ * and pushes the updated workspace configs to `AttachmentService`.
1639
+ */
1640
+ updateLinearWorkspaceTokens(newConfig) {
1641
+ const oldWorkspaces = this.config.linearWorkspaces ?? {};
1642
+ const newWorkspaces = newConfig.linearWorkspaces ?? {};
1643
+ let anyTokenChanged = false;
1644
+ for (const [workspaceId, newWsConfig] of Object.entries(newWorkspaces)) {
1645
+ const oldToken = oldWorkspaces[workspaceId]?.linearToken;
1646
+ const newToken = newWsConfig.linearToken;
1647
+ if (oldToken === newToken)
1648
+ continue;
1649
+ anyTokenChanged = true;
1650
+ // Update existing issue tracker in-place
1651
+ const issueTracker = this.issueTrackers.get(workspaceId);
1652
+ if (issueTracker) {
1653
+ issueTracker.setAccessToken(newToken);
1654
+ this.logger.info(`🔑 Updated Linear token for workspace ${workspaceId}`);
1655
+ }
1656
+ else if (this.config.platform !== "cli") {
1657
+ // Workspace is new — create a tracker for it
1658
+ const newIssueTracker = new LinearIssueTrackerService(new LinearClient({ accessToken: newToken }), this.buildOAuthConfig(workspaceId));
1659
+ this.issueTrackers.set(workspaceId, newIssueTracker);
1660
+ this.logger.info(`🔑 Created issue tracker for new workspace ${workspaceId}`);
1661
+ }
1662
+ }
1663
+ if (anyTokenChanged) {
1664
+ // Push refreshed workspace configs to AttachmentService
1665
+ this.attachmentService.setLinearWorkspaces(newWorkspaces);
1666
+ }
1667
+ }
1231
1668
  /**
1232
1669
  * Add new repositories to the running EdgeWorker
1233
1670
  */
@@ -2173,6 +2610,13 @@ ${taskSection}`;
2173
2610
  finalClassification = routingDecision.classification;
2174
2611
  log.info(`AI routing: ${routingDecision.classification} → ${finalProcedure.name}`);
2175
2612
  }
2613
+ // Apply platform-specific subroutine substitution (e.g., gh-pr → glab-mr for GitLab repos)
2614
+ const hostingPlatform = primaryRepo.gitlabUrl
2615
+ ? "gitlab"
2616
+ : primaryRepo.githubUrl
2617
+ ? "github"
2618
+ : undefined;
2619
+ finalProcedure = applyPlatformSubroutines(finalProcedure, hostingPlatform);
2176
2620
  // Initialize procedure metadata in session with final decision
2177
2621
  this.procedureAnalyzer.initializeProcedureMetadata(session, finalProcedure);
2178
2622
  // Post single procedure selection result (replaces ephemeral routing thought)
@@ -3605,6 +4049,13 @@ ${input.userComment}
3605
4049
  finalClassification = routingDecision.classification;
3606
4050
  this.logger.info(`AI routing: ${routingDecision.classification} → ${selectedProcedure.name}`);
3607
4051
  }
4052
+ // Apply platform-specific subroutine substitution (e.g., gh-pr → glab-mr for GitLab repos)
4053
+ const hostingPlatform = repository.gitlabUrl
4054
+ ? "gitlab"
4055
+ : repository.githubUrl
4056
+ ? "github"
4057
+ : undefined;
4058
+ selectedProcedure = applyPlatformSubroutines(selectedProcedure, hostingPlatform);
3608
4059
  // Initialize procedure metadata in session (resets currentSubroutine)
3609
4060
  this.procedureAnalyzer.initializeProcedureMetadata(session, selectedProcedure);
3610
4061
  // Post procedure selection result (replaces ephemeral routing thought)