@infinisys/is-devops 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,948 @@
1
+ /**
2
+ * Host half of the Infinity DevOps bundle.
3
+ *
4
+ * Two surfaces share one GitFox client:
5
+ *
6
+ * - an authenticated same-origin proxy (`/infinity/devops/api/v1/...`) for the
7
+ * Client panel — the browser never sees the access token;
8
+ * - one compact agent tool, `infinity_devops`, for the Fabric AI team and any
9
+ * other agent that needs to read or change issues, merge requests, commits,
10
+ * branches, and projects.
11
+ *
12
+ * The `infinityDevOps` Host service exposes the same client to sibling Host
13
+ * plugins, which keeps future Fabric automation on one integration seam.
14
+ */
15
+ import { botNameFor, defaultActorRecord, normalizeActorRecord } from './lib/bot-identity.js';
16
+ import { createGitRepository } from './lib/git-repository.js';
17
+ import { createGitTool } from './lib/git-tool.js';
18
+ import { projectFromCloneUrl, undiscovered } from './lib/project-discovery.js';
19
+ import {
20
+ GitFoxError,
21
+ createGitFoxClient,
22
+ readRequestBody,
23
+ withQuery,
24
+ } from './lib/gitfox-api.js';
25
+
26
+ const PROXY_PREFIX = '/infinity/devops';
27
+ const MAX_PROXY_BODY_BYTES = 5 * 1024 * 1024;
28
+ const PROXY_TIMEOUT_MS = 30_000;
29
+ /** Custom header required on every browser call (CSRF / cross-origin guard). */
30
+ const REQUEST_HEADER = 'x-infinity-request';
31
+ const PROXY_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];
32
+ const ISSUE_WRITABLE_FIELDS = ['title', 'description', 'state', 'labels'];
33
+ /** Fields `update_merge_request` may rewrite; the rest of the record stays as written. */
34
+ const MERGE_REQUEST_WRITABLE_FIELDS = ['title', 'description'];
35
+
36
+ const TOOL_DESCRIPTION =
37
+ 'Read and manage GitFox DevOps resources for the Infinity System: projects, issues, merge requests, their comments, commits and branches. Connect GitFox once in Settings → Infinity System. Project references use "namespace/name"; issue and merge-request ids are project-scoped iids. Agent-created issues and merge requests are attributed to the infinsys-* bot identity; comments are written as the connected account because GitFox notes have no bot field. A remark about an existing issue or merge request belongs in its thread ("create_issue_note" / "create_merge_request_note"), not in a new issue.';
38
+
39
+ const TOOL_PARAMETERS = {
40
+ type: 'object',
41
+ additionalProperties: false,
42
+ properties: {
43
+ action: {
44
+ type: 'string',
45
+ enum: [
46
+ 'status',
47
+ 'projects',
48
+ 'issues',
49
+ 'issue',
50
+ 'create_issue',
51
+ 'update_issue',
52
+ 'issue_notes',
53
+ 'create_issue_note',
54
+ 'merge_requests',
55
+ 'create_merge_request',
56
+ 'merge_merge_request',
57
+ 'update_merge_request',
58
+ 'close_merge_request',
59
+ 'merge_request_notes',
60
+ 'create_merge_request_note',
61
+ 'branches',
62
+ 'create_branch',
63
+ 'delete_branch',
64
+ 'commits',
65
+ 'commit',
66
+ ],
67
+ description:
68
+ 'Operation to perform. "status" checks the connection; "projects" lists visible projects; list actions accept page/per_page. "*_notes" read the comments of one issue or merge request; "create_*_note" posts a comment instead of editing the record.',
69
+ },
70
+ project: {
71
+ type: 'string',
72
+ description: 'Project reference in "namespace/name" form.',
73
+ },
74
+ iid: {
75
+ type: 'integer',
76
+ minimum: 1,
77
+ description: 'Project-scoped issue or merge-request id.',
78
+ },
79
+ sha: { type: 'string', description: 'Commit SHA for action "commit".' },
80
+ branch: { type: 'string', description: 'Branch name for "delete_branch".' },
81
+ ref: {
82
+ type: 'string',
83
+ description: 'Source revision for "create_branch" or commit filter for "commits".',
84
+ },
85
+ name: { type: 'string', description: 'New branch name for "create_branch".' },
86
+ title: { type: 'string', description: 'Issue or merge-request title.' },
87
+ description: { type: 'string', description: 'Issue or merge-request description.' },
88
+ body: {
89
+ type: 'string',
90
+ description: 'Comment text for "create_issue_note" and "create_merge_request_note".',
91
+ },
92
+ state: {
93
+ type: 'string',
94
+ enum: ['open', 'closed', 'all'],
95
+ description: 'Issue filter or new state for "update_issue".',
96
+ },
97
+ status: {
98
+ type: 'string',
99
+ enum: ['open', 'merged', 'closed', 'draft'],
100
+ description: 'Merge-request status filter.',
101
+ },
102
+ labels: {
103
+ type: 'array',
104
+ items: { type: 'string' },
105
+ description: 'Issue labels.',
106
+ },
107
+ source_branch: { type: 'string', description: 'Merge-request source branch.' },
108
+ target_branch: { type: 'string', description: 'Merge-request target branch.' },
109
+ is_draft: { type: 'boolean', description: 'Create the merge request as a draft.' },
110
+ squash: { type: 'boolean', description: 'Squash commits while merging.' },
111
+ delete_source_branch: {
112
+ type: 'boolean',
113
+ description: 'Delete the source branch after a merge.',
114
+ },
115
+ page: { type: 'integer', minimum: 1, description: 'Page number, starting at 1.' },
116
+ per_page: {
117
+ type: 'integer',
118
+ minimum: 1,
119
+ maximum: 100,
120
+ description: 'Page size, 1–100.',
121
+ },
122
+ },
123
+ required: ['action'],
124
+ };
125
+
126
+ /**
127
+ * Whether one socket address is a loopback address.
128
+ *
129
+ * @param {string} address - `remoteAddress` or `localAddress` text.
130
+ * @returns {boolean} loopback status.
131
+ */
132
+ function isLoopbackAddress(address) {
133
+ const text = address.startsWith('[') ? address.slice(1, address.indexOf(']')) : address;
134
+ const bare = text.split('%')[0];
135
+ if (bare === '::1') return true;
136
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(bare);
137
+ return /^127\./.test(mapped === null ? bare : mapped[1]);
138
+ }
139
+
140
+ /**
141
+ * Whether the request may use the Fabric HTTP surface.
142
+ *
143
+ * Two things make a request local: it comes from loopback, or it arrives on the
144
+ * address the Harness listener itself is bound to. The second case is the
145
+ * operator's own choice — a listener bound to a network address serves that
146
+ * network — so following the listener keeps the panel working exactly where the
147
+ * GUI URL points instead of failing behind a loopback-only guard.
148
+ * `INFINITY_ALLOW_NON_LOOPBACK=1` stays as the explicit override for
149
+ * deployments that terminate authentication in front of the Harness.
150
+ *
151
+ * @param {import('node:http').IncomingMessage} req - incoming request.
152
+ * @returns {boolean} whether the peer may use the route.
153
+ */
154
+ function isLocalRequest(req) {
155
+ if (process.env.INFINITY_ALLOW_NON_LOOPBACK === '1') return true;
156
+ const socket = req.socket;
157
+ if (socket === undefined) return true;
158
+ if (socket.remoteAddress === undefined) return true;
159
+ if (isLoopbackAddress(socket.remoteAddress)) return true;
160
+ // The connection's local address is the listener's bind address, so it names
161
+ // the interface this request actually reached. Loopback there means the
162
+ // Harness never offered the port to the network.
163
+ return socket.localAddress !== undefined && !isLoopbackAddress(socket.localAddress);
164
+ }
165
+
166
+ /**
167
+ * Limit the proxy to the DevOps resources the panel actually uses.
168
+ *
169
+ * @param {string} method - HTTP method.
170
+ * @param {string} pathname - decoded URL pathname.
171
+ * @returns {boolean} whether the proxy may forward the request.
172
+ */
173
+ function isAllowedProxyRequest(method, pathname) {
174
+ if (pathname === '/api/v1/projects') return method === 'GET' || method === 'HEAD';
175
+ const project = /^\/api\/v1\/projects\/[^/]+\/[^/]+\/(issues|merge_requests)(?:\/\d+)?(?:\/merge)?$/;
176
+ const match = project.exec(pathname);
177
+ if (match !== null) return ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'].includes(method);
178
+ if (/^\/api\/v1\/projects\/[^/]+\/[^/]+\/repository\/branches(?:\/.*)?$/.test(pathname)) {
179
+ return ['GET', 'HEAD', 'POST', 'DELETE'].includes(method);
180
+ }
181
+ if (/^\/api\/v1\/projects\/[^/]+\/[^/]+\/repository\/commits(?:\/[^/]+)?$/.test(pathname)) {
182
+ return method === 'GET' || method === 'HEAD';
183
+ }
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * Write one JSON error response.
189
+ *
190
+ * @param {import('node:http').ServerResponse} res - response to own.
191
+ * @param {number} status - HTTP status code.
192
+ * @param {string} code - stable machine code.
193
+ * @param {string} message - human-readable detail.
194
+ * @returns {void}
195
+ */
196
+ function sendError(res, status, code, message) {
197
+ const body = JSON.stringify({ error: { code, message } });
198
+ res.writeHead(status, {
199
+ 'content-type': 'application/json; charset=utf-8',
200
+ 'cache-control': 'no-store',
201
+ 'content-length': Buffer.byteLength(body),
202
+ });
203
+ res.end(body);
204
+ }
205
+
206
+ /**
207
+ * Throw when one required tool argument is absent.
208
+ *
209
+ * @param {Record<string, unknown>} args - tool arguments.
210
+ * @param {string} key - required field.
211
+ * @returns {void}
212
+ */
213
+ function requireArg(args, key) {
214
+ if (args[key] === undefined || args[key] === null || args[key] === '') {
215
+ throw new GitFoxError(`action "${args.action}" requires "${key}"`, { code: 'invalid-arguments' });
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Require an integer tool argument.
221
+ *
222
+ * @param {Record<string, unknown>} args - tool arguments.
223
+ * @param {string} key - required field.
224
+ * @returns {number} integer value.
225
+ */
226
+ function requireInt(args, key) {
227
+ requireArg(args, key);
228
+ const value = Number(args[key]);
229
+ if (!Number.isInteger(value) || value < 1) {
230
+ throw new GitFoxError(`"${key}" must be a positive integer`, { code: 'invalid-arguments' });
231
+ }
232
+ return value;
233
+ }
234
+
235
+ /**
236
+ * Require and return the `project` argument.
237
+ *
238
+ * @param {Record<string, unknown>} args - tool arguments.
239
+ * @returns {string} project reference.
240
+ */
241
+ function requireProject(args) {
242
+ requireArg(args, 'project');
243
+ return /** @type {string} */ (args.project);
244
+ }
245
+
246
+ /**
247
+ * Format a tool value without letting one huge API response dominate context.
248
+ *
249
+ * @param {unknown} value - canonical tool value.
250
+ * @returns {string} model-facing text.
251
+ */
252
+ function formatValue(value) {
253
+ const text = JSON.stringify(value, null, 2) ?? 'null';
254
+ return text.length <= 24_000 ? text : `${text.slice(0, 24_000)}\n… (truncated)`;
255
+ }
256
+
257
+ /**
258
+ * Resolve the GitFox connection through settings on every request.
259
+ *
260
+ * @param {{ infinitySettings: { resolveGitFox: () => Promise<{ baseUrl: string, token: string }> } }} ctx - Host context.
261
+ * @returns {Promise<{ baseUrl: string, token: string }>} connection.
262
+ */
263
+ async function resolveGitFoxConfig(ctx) {
264
+ const connection = await ctx.infinitySettings.resolveGitFox();
265
+ return { baseUrl: connection.baseUrl, token: connection.token };
266
+ }
267
+
268
+ /**
269
+ * Validate one proxy request and derive its upstream target.
270
+ *
271
+ * @param {import('node:http').IncomingMessage} req - incoming request.
272
+ * @param {import('node:http').ServerResponse} res - response to own.
273
+ * @returns {{ method: string, rest: string } | null} target, or null after an error was sent.
274
+ */
275
+ function proxyTarget(req, res) {
276
+ if (!isLocalRequest(req)) {
277
+ sendError(
278
+ res,
279
+ 403,
280
+ 'loopback-only',
281
+ 'The DevOps proxy serves this computer only. Restart the Harness on one network address to serve that network.',
282
+ );
283
+ return null;
284
+ }
285
+ const method = req.method ?? 'GET';
286
+ if (req.headers[REQUEST_HEADER] !== '1') {
287
+ sendError(res, 403, 'csrf-guard', 'Missing request marker.');
288
+ return null;
289
+ }
290
+ if (!PROXY_METHODS.includes(method)) {
291
+ sendError(res, 405, 'method-not-allowed', `${method} is not supported`);
292
+ return null;
293
+ }
294
+
295
+ const url = new URL(req.url ?? '/', 'http://localhost');
296
+ const apiPath = url.pathname.slice(PROXY_PREFIX.length);
297
+ if (!apiPath.startsWith('/api/v1/')) {
298
+ sendError(res, 404, 'not-an-api-path', 'Only /infinity/devops/api/v1/* is proxied');
299
+ return null;
300
+ }
301
+ if (apiPath.includes('..') || apiPath.includes('\\')) {
302
+ sendError(res, 400, 'invalid-path', 'Path traversal is not allowed');
303
+ return null;
304
+ }
305
+ if (!isAllowedProxyRequest(method, apiPath)) {
306
+ sendError(res, 403, 'proxy-path-not-allowed', 'This DevOps resource is not exposed by the panel proxy.');
307
+ return null;
308
+ }
309
+ return { method, rest: `${apiPath}${url.search}` };
310
+ }
311
+
312
+ /**
313
+ * Build the upstream headers of one proxy call.
314
+ *
315
+ * @param {import('node:http').IncomingMessage} req - incoming request.
316
+ * @param {string} token - GitFox access token.
317
+ * @returns {Record<string, string>} headers.
318
+ */
319
+ function proxyHeaders(req, token) {
320
+ return {
321
+ accept: typeof req.headers.accept === 'string' ? req.headers.accept : 'application/json',
322
+ authorization: `Bearer ${token}`,
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Read the proxy request body for mutating methods.
328
+ *
329
+ * @param {import('node:http').IncomingMessage} req - incoming request.
330
+ * @param {string} method - HTTP method.
331
+ * @returns {Promise<Buffer | undefined>} body bytes, or undefined for GET/HEAD.
332
+ */
333
+ async function proxyBody(req, method) {
334
+ if (method === 'GET' || method === 'HEAD') return undefined;
335
+ return readRequestBody(req, MAX_PROXY_BODY_BYTES);
336
+ }
337
+
338
+ /**
339
+ * Forward one validated request to GitFox and stream its answer back.
340
+ *
341
+ * @param {object} ctx - Host context.
342
+ * @param {import('node:http').IncomingMessage} req - incoming request.
343
+ * @param {import('node:http').ServerResponse} res - response to own.
344
+ * @param {{ method: string, rest: string }} target - validated upstream target.
345
+ * @returns {Promise<void>}
346
+ */
347
+ async function forwardProxyRequest(ctx, req, res, target) {
348
+ const connection = await ctx.infinitySettings.resolveGitFox();
349
+ if (connection.token === '') {
350
+ sendError(
351
+ res,
352
+ 503,
353
+ 'not-configured',
354
+ 'GitFox is not connected. Open Settings → Infinity System and add an access token.',
355
+ );
356
+ return;
357
+ }
358
+
359
+ const headers = proxyHeaders(req, connection.token);
360
+ const body = await proxyBody(req, target.method);
361
+ if (body !== undefined && body.length > 0) {
362
+ headers['content-type'] =
363
+ typeof req.headers['content-type'] === 'string'
364
+ ? req.headers['content-type']
365
+ : 'application/json';
366
+ }
367
+
368
+ const upstream = await fetch(`${connection.baseUrl.replace(/\/+$/, '')}${target.rest}`, {
369
+ method: target.method,
370
+ headers,
371
+ body,
372
+ signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
373
+ });
374
+ const bytes = Buffer.from(await upstream.arrayBuffer());
375
+ const total = upstream.headers.get('x-total');
376
+ res.writeHead(upstream.status, {
377
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json; charset=utf-8',
378
+ 'cache-control': 'no-store',
379
+ 'content-length': bytes.length,
380
+ ...(total === null ? {} : { 'x-total': total }),
381
+ });
382
+ res.end(bytes);
383
+ }
384
+
385
+ /**
386
+ * Map a proxy failure onto a JSON error response.
387
+ *
388
+ * @param {import('node:http').ServerResponse} res - response to own.
389
+ * @param {any} error - thrown value.
390
+ * @returns {void}
391
+ */
392
+ function proxyErrorResponse(res, error) {
393
+ if (error instanceof GitFoxError) {
394
+ sendError(res, error.status === 0 ? 502 : error.status, error.code, error.message);
395
+ return;
396
+ }
397
+ if (typeof error?.statusCode === 'number' && error.statusCode > 0) {
398
+ sendError(
399
+ res,
400
+ error.statusCode,
401
+ typeof error.code === 'string' ? error.code : 'auth-failed',
402
+ error instanceof Error ? error.message : String(error),
403
+ );
404
+ return;
405
+ }
406
+ if (error instanceof Error && error.name === 'TimeoutError') {
407
+ sendError(res, 504, 'timeout', 'GitFox did not answer within 30 seconds.');
408
+ return;
409
+ }
410
+ sendError(
411
+ res,
412
+ 502,
413
+ 'unreachable',
414
+ `Cannot reach GitFox: ${error instanceof Error ? error.message : String(error)}`,
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Handle one authenticated proxy request.
420
+ *
421
+ * @param {object} ctx - Host context.
422
+ * @param {import('node:http').IncomingMessage} req - incoming request.
423
+ * @param {import('node:http').ServerResponse} res - response to own.
424
+ * @returns {Promise<void>}
425
+ */
426
+ async function handleProxy(ctx, req, res) {
427
+ const target = proxyTarget(req, res);
428
+ if (target === null) return;
429
+ try {
430
+ await forwardProxyRequest(ctx, req, res, target);
431
+ } catch (error) {
432
+ proxyErrorResponse(res, error);
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Register the same-origin proxy route.
438
+ *
439
+ * @param {object} ctx - Host context.
440
+ * @returns {void}
441
+ */
442
+ function registerProxyRoutes(ctx) {
443
+ ctx.effect(
444
+ () =>
445
+ ctx.webServer.register({
446
+ kind: 'prefix',
447
+ path: PROXY_PREFIX,
448
+ handler: (req, res) => handleProxy(ctx, req, res),
449
+ }),
450
+ 'is-devops: authenticated GitFox API proxy',
451
+ );
452
+ }
453
+
454
+ /**
455
+ * @param {Record<string, unknown>} args - tool arguments.
456
+ * @returns {{ page: number, per_page: number }} paging query.
457
+ */
458
+ function pageQuery(args) {
459
+ return { page: args.page ?? 1, per_page: args.per_page ?? 50 };
460
+ }
461
+
462
+ /**
463
+ * @param {object} api - GitFox client.
464
+ * @returns {Promise<unknown>} the authenticated user, or null when unconfigured.
465
+ */
466
+ async function currentUser(api) {
467
+ try {
468
+ return await api.me();
469
+ } catch (error) {
470
+ if (!(error instanceof GitFoxError) || error.code !== 'not-configured') throw error;
471
+ return null;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * @param {{ ctx: object, api: object }} deps - action dependencies.
477
+ * @returns {Promise<{ result: unknown }>} status result.
478
+ */
479
+ async function handleStatus({ ctx, api }) {
480
+ const connection = await ctx.infinitySettings.getGitFoxStatus();
481
+ const user = await currentUser(api);
482
+ return { result: { connection, user: user ?? null } };
483
+ }
484
+
485
+ /**
486
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
487
+ * @returns {Promise<{ result: unknown }>} projects result.
488
+ */
489
+ async function handleProjects({ api, args }) {
490
+ return { result: await api.listProjects(pageQuery(args)) };
491
+ }
492
+
493
+ /**
494
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
495
+ * @returns {Promise<{ project: string, result: unknown }>} issue list result.
496
+ */
497
+ async function handleIssues({ api, args }) {
498
+ const project = requireProject(args);
499
+ const result = await api.listIssues(project, {
500
+ state: args.state ?? 'open',
501
+ ...pageQuery(args),
502
+ });
503
+ return { project, result };
504
+ }
505
+
506
+ /**
507
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
508
+ * @returns {Promise<{ project: string, result: unknown }>} issue detail.
509
+ */
510
+ async function handleIssue({ api, args }) {
511
+ const project = requireProject(args);
512
+ return { project, result: await api.getIssue(project, requireInt(args, 'iid')) };
513
+ }
514
+
515
+ /**
516
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
517
+ * @returns {Promise<{ project: string, result: unknown }>} created issue.
518
+ */
519
+ async function handleCreateIssue({ api, args, actor }) {
520
+ const project = requireProject(args);
521
+ requireArg(args, 'title');
522
+ const body = { title: args.title };
523
+ if (args.description !== undefined) body.description = args.description;
524
+ if (Array.isArray(args.labels)) body.labels = args.labels;
525
+ return { project, result: await api.createIssue(project, body, { botName: actor.botName }) };
526
+ }
527
+
528
+ /**
529
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
530
+ * @returns {Promise<{ project: string, result: unknown }>} updated issue.
531
+ */
532
+ async function handleUpdateIssue({ api, args }) {
533
+ const project = requireProject(args);
534
+ const iid = requireInt(args, 'iid');
535
+ const body = {};
536
+ for (const field of ISSUE_WRITABLE_FIELDS) {
537
+ if (args[field] !== undefined) body[field] = args[field];
538
+ }
539
+ return { project, result: await api.updateIssue(project, iid, body) };
540
+ }
541
+
542
+ /**
543
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
544
+ * @returns {Promise<{ project: string, result: unknown }>} issue comments.
545
+ */
546
+ async function handleIssueNotes({ api, args }) {
547
+ const project = requireProject(args);
548
+ return { project, result: await api.listIssueNotes(project, requireInt(args, 'iid')) };
549
+ }
550
+
551
+ /**
552
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
553
+ * @returns {Promise<{ project: string, result: unknown }>} created comment.
554
+ */
555
+ async function handleCreateIssueNote({ api, args }) {
556
+ const project = requireProject(args);
557
+ const iid = requireInt(args, 'iid');
558
+ requireArg(args, 'body');
559
+ return { project, result: await api.addIssueNote(project, iid, { body: args.body }) };
560
+ }
561
+
562
+ /**
563
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
564
+ * @returns {Promise<{ project: string, result: unknown }>} merge request list.
565
+ */
566
+ async function handleMergeRequests({ api, args }) {
567
+ const project = requireProject(args);
568
+ const query = {};
569
+ if (args.status !== undefined) query.status = args.status;
570
+ Object.assign(query, pageQuery(args));
571
+ return { project, result: await api.listMergeRequests(project, query) };
572
+ }
573
+
574
+ /**
575
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
576
+ * @returns {Promise<{ project: string, result: unknown }>} created merge request.
577
+ */
578
+ async function handleCreateMergeRequest({ api, args, actor }) {
579
+ const project = requireProject(args);
580
+ requireArg(args, 'title');
581
+ requireArg(args, 'source_branch');
582
+ requireArg(args, 'target_branch');
583
+ const body = {
584
+ title: args.title,
585
+ source_branch: args.source_branch,
586
+ target_branch: args.target_branch,
587
+ };
588
+ if (args.description !== undefined) body.description = args.description;
589
+ if (args.is_draft !== undefined) body.is_draft = args.is_draft;
590
+ return {
591
+ project,
592
+ result: await api.createMergeRequest(project, body, { botName: actor.botName }),
593
+ };
594
+ }
595
+
596
+ /**
597
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
598
+ * @returns {Promise<{ project: string, result: unknown }>} merge result.
599
+ */
600
+ async function handleMergeMergeRequest({ api, args }) {
601
+ const project = requireProject(args);
602
+ const iid = requireInt(args, 'iid');
603
+ const body = {};
604
+ if (args.squash !== undefined) body.squash = args.squash;
605
+ if (args.delete_source_branch !== undefined) {
606
+ body.delete_source_branch = args.delete_source_branch;
607
+ }
608
+ return { project, result: await api.mergeMergeRequest(project, iid, body) };
609
+ }
610
+
611
+ /**
612
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
613
+ * @returns {Promise<{ project: string, result: unknown }>} updated merge request.
614
+ */
615
+ async function handleUpdateMergeRequest({ api, args }) {
616
+ const project = requireProject(args);
617
+ const iid = requireInt(args, 'iid');
618
+ const body = {};
619
+ for (const field of MERGE_REQUEST_WRITABLE_FIELDS) {
620
+ if (args[field] !== undefined) body[field] = args[field];
621
+ }
622
+ if (Object.keys(body).length === 0) {
623
+ throw new GitFoxError(
624
+ `action "update_merge_request" requires "title" or "description"`,
625
+ { code: 'invalid-arguments' },
626
+ );
627
+ }
628
+ return { project, result: await api.updateMergeRequest(project, iid, body) };
629
+ }
630
+
631
+ /**
632
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
633
+ * @returns {Promise<{ project: string, result: unknown }>} closed merge request.
634
+ */
635
+ async function handleCloseMergeRequest({ api, args }) {
636
+ const project = requireProject(args);
637
+ const iid = requireInt(args, 'iid');
638
+ const result = await api.updateMergeRequest(project, iid, { state: 'closed' });
639
+ // A server that predates merge-request state support ignores the field and
640
+ // answers 200, so read the record back instead of trusting the status code.
641
+ const status = result?.status ?? result?.merge_request?.status;
642
+ if (status !== 'closed') {
643
+ throw new GitFoxError(
644
+ `the server accepted the request but merge request ${iid} is "${status ?? 'unknown'}", not closed; this GitFox build has no merge-request state update`,
645
+ { code: 'close-not-applied' },
646
+ );
647
+ }
648
+ return { project, result };
649
+ }
650
+
651
+ /**
652
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
653
+ * @returns {Promise<{ project: string, result: unknown }>} merge-request comments.
654
+ */
655
+ async function handleMergeRequestNotes({ api, args }) {
656
+ const project = requireProject(args);
657
+ return { project, result: await api.listMergeRequestNotes(project, requireInt(args, 'iid')) };
658
+ }
659
+
660
+ /**
661
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
662
+ * @returns {Promise<{ project: string, result: unknown }>} created comment.
663
+ */
664
+ async function handleCreateMergeRequestNote({ api, args }) {
665
+ const project = requireProject(args);
666
+ const iid = requireInt(args, 'iid');
667
+ requireArg(args, 'body');
668
+ return { project, result: await api.addMergeRequestNote(project, iid, { body: args.body }) };
669
+ }
670
+
671
+ /**
672
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
673
+ * @returns {Promise<{ project: string, result: unknown }>} branch list.
674
+ */
675
+ async function handleBranches({ api, args }) {
676
+ const project = requireProject(args);
677
+ return { project, result: await api.listBranches(project) };
678
+ }
679
+
680
+ /**
681
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
682
+ * @returns {Promise<{ project: string, result: unknown }>} created branch.
683
+ */
684
+ async function handleCreateBranch({ api, args }) {
685
+ const project = requireProject(args);
686
+ requireArg(args, 'name');
687
+ requireArg(args, 'ref');
688
+ return { project, result: await api.createBranch(project, { name: args.name, ref: args.ref }) };
689
+ }
690
+
691
+ /**
692
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
693
+ * @returns {Promise<{ project: string, result: unknown }>} deletion result.
694
+ */
695
+ async function handleDeleteBranch({ api, args }) {
696
+ const project = requireProject(args);
697
+ requireArg(args, 'branch');
698
+ return { project, result: await api.deleteBranch(project, args.branch) };
699
+ }
700
+
701
+ /**
702
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
703
+ * @returns {Promise<{ project: string, result: unknown }>} commit list.
704
+ */
705
+ async function handleCommits({ api, args }) {
706
+ const project = requireProject(args);
707
+ const result = await api.listCommits(project, {
708
+ ref: args.ref,
709
+ ...pageQuery(args),
710
+ });
711
+ return { project, result };
712
+ }
713
+
714
+ /**
715
+ * @param {{ api: object, args: Record<string, unknown> }} deps - action dependencies.
716
+ * @returns {Promise<{ project: string, result: unknown }>} commit detail.
717
+ */
718
+ async function handleCommit({ api, args }) {
719
+ const project = requireProject(args);
720
+ requireArg(args, 'sha');
721
+ return { project, result: await api.getCommit(project, args.sha) };
722
+ }
723
+
724
+ /** One handler per supported tool action. */
725
+ const ACTION_HANDLERS = {
726
+ status: handleStatus,
727
+ projects: handleProjects,
728
+ issues: handleIssues,
729
+ issue: handleIssue,
730
+ create_issue: handleCreateIssue,
731
+ update_issue: handleUpdateIssue,
732
+ issue_notes: handleIssueNotes,
733
+ create_issue_note: handleCreateIssueNote,
734
+ merge_requests: handleMergeRequests,
735
+ create_merge_request: handleCreateMergeRequest,
736
+ merge_merge_request: handleMergeMergeRequest,
737
+ update_merge_request: handleUpdateMergeRequest,
738
+ close_merge_request: handleCloseMergeRequest,
739
+ merge_request_notes: handleMergeRequestNotes,
740
+ create_merge_request_note: handleCreateMergeRequestNote,
741
+ branches: handleBranches,
742
+ create_branch: handleCreateBranch,
743
+ delete_branch: handleDeleteBranch,
744
+ commits: handleCommits,
745
+ commit: handleCommit,
746
+ };
747
+
748
+ /**
749
+ * Normalize one handler outcome into the canonical tool result.
750
+ *
751
+ * @param {string} action - executed action.
752
+ * @param {{ project?: string, result: unknown }} outcome - handler outcome.
753
+ * @returns {{ action: string, project?: string, result: unknown }} canonical result.
754
+ */
755
+ function finalizeAction(action, outcome) {
756
+ const result = outcome.result ?? null;
757
+ if (outcome.project === undefined) return { action, result };
758
+ return { action, project: outcome.project, result };
759
+ }
760
+
761
+ /**
762
+ * Execute one DevOps action by name.
763
+ *
764
+ * @param {object} ctx - Host context.
765
+ * @param {object} api - GitFox client.
766
+ * @param {Record<string, any>} args - validated arguments.
767
+ * @returns {Promise<object>} canonical result.
768
+ */
769
+ async function executeAction(ctx, api, args, actor) {
770
+ const handler = ACTION_HANDLERS[args.action];
771
+ if (handler === undefined) {
772
+ throw new GitFoxError(`unknown action "${args.action}"`, { code: 'invalid-arguments' });
773
+ }
774
+ return finalizeAction(args.action, await handler({ ctx, api, args, actor }));
775
+ }
776
+
777
+ /**
778
+ * Execute one action, normalizing GitFox errors into tool errors.
779
+ *
780
+ * @param {object} ctx - Host context.
781
+ * @param {object} api - GitFox client.
782
+ * @param {Record<string, any>} args - validated arguments.
783
+ * @returns {Promise<object>} canonical result.
784
+ */
785
+ async function runAction(ctx, api, args, actor) {
786
+ try {
787
+ return await executeAction(ctx, api, args, actor);
788
+ } catch (error) {
789
+ if (error instanceof GitFoxError) {
790
+ throw new Error(`${error.code}: ${error.message}`, { cause: error });
791
+ }
792
+ throw error;
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Build the `infinity_devops` tool definition.
798
+ *
799
+ * @param {object} ctx - Host context.
800
+ * @param {object} api - GitFox client.
801
+ * @returns {object} tool definition.
802
+ */
803
+ function createTool(ctx, api) {
804
+ return {
805
+ name: 'infinity_devops',
806
+ description: TOOL_DESCRIPTION,
807
+ parameters: TOOL_PARAMETERS,
808
+ output: {
809
+ schema: { type: 'object', additionalProperties: true },
810
+ /**
811
+ * @param {unknown} _args - validated arguments.
812
+ * @param {unknown} value - canonical value.
813
+ * @returns {Array<{ type: 'text', text: string }>} model-facing content.
814
+ */
815
+ render(_args, value) {
816
+ return [{ type: 'text', text: formatValue(value) }];
817
+ },
818
+ },
819
+ /**
820
+ * Execute one DevOps action.
821
+ *
822
+ * @param {Record<string, any>} args - validated arguments.
823
+ * @param {{ signal?: AbortSignal }} exec - execution context.
824
+ * @returns {Promise<object>} canonical result.
825
+ */
826
+ async execute(args, exec) {
827
+ exec.signal?.throwIfAborted();
828
+ const actor = await api.resolveActor(exec.agent);
829
+ const result = await runAction(ctx, api, args, actor);
830
+ exec.signal?.throwIfAborted();
831
+ return result;
832
+ },
833
+ };
834
+ }
835
+
836
+ /**
837
+ * Add sibling-plugin actor resolution to the GitFox client.
838
+ *
839
+ * `is-fabric-team` registers a resolver that maps a live session to its team
840
+ * role. Without it, an ordinary Agent session gets the unrestricted
841
+ * `infinsys-agent` actor, which preserves pre-existing behaviour outside Team
842
+ * Collaboration Mode.
843
+ *
844
+ * @param {object} api - GitFox client.
845
+ * @returns {object} Host DevOps service.
846
+ */
847
+ function createDevOpsService(api, git) {
848
+ const resolvers = new Set();
849
+ return {
850
+ ...api,
851
+ botNameFor,
852
+ /**
853
+ * Discover the GitFox project named by the workspace's origin remote.
854
+ *
855
+ * @param {string} workspaceRoot - canonical session workspace.
856
+ * @param {{ signal?: AbortSignal }} [options] - cancellation.
857
+ * @returns {Promise<{ project: string | null, remoteUrl: string | null, reason?: string }>} discovery result.
858
+ */
859
+ async discoverProject(workspaceRoot, options = {}) {
860
+ let remoteUrl;
861
+ try {
862
+ const remote = await git.remoteUrl(undefined, {
863
+ workspaceRoot,
864
+ signal: options.signal,
865
+ });
866
+ remoteUrl = remote.remote;
867
+ } catch {
868
+ return undiscovered('no-remote');
869
+ }
870
+ if (remoteUrl === null) return undiscovered('no-remote');
871
+ let config;
872
+ try {
873
+ config = await api.getConfig(options.signal === undefined ? {} : { signal: options.signal });
874
+ } catch {
875
+ return undiscovered('config-unavailable', remoteUrl);
876
+ }
877
+ const project = projectFromCloneUrl(remoteUrl, config);
878
+ return project === null ? undiscovered('unknown-remote', remoteUrl) : { project, remoteUrl };
879
+ },
880
+ /**
881
+ * Read the staged entries of one workspace's index.
882
+ *
883
+ * Sibling plugins need the index as data, not just as a commit step: the
884
+ * work-record gate has to know which paths a commit would put in the tree,
885
+ * and whether each one is an addition or a deletion.
886
+ *
887
+ * @param {string} workspaceRoot - canonical session workspace.
888
+ * @param {{ signal?: AbortSignal, actor?: object }} [options] - cancellation and identity.
889
+ * @returns {Promise<{ path: string, status: string }[]>} staged entries.
890
+ */
891
+ async stagedFiles(workspaceRoot, options = {}) {
892
+ if (typeof workspaceRoot !== 'string' || workspaceRoot === '') return [];
893
+ return git.stagedFiles(options.actor, {
894
+ workspaceRoot,
895
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
896
+ });
897
+ },
898
+ /**
899
+ * @param {object} [agent] - live Agent.
900
+ * @returns {Promise<object>} resolved actor record.
901
+ */
902
+ async resolveActor(agent) {
903
+ for (const resolver of [...resolvers].reverse()) {
904
+ const actor = await resolver(agent);
905
+ if (actor !== undefined && actor !== null) return normalizeActorRecord(actor);
906
+ }
907
+ return defaultActorRecord();
908
+ },
909
+ /**
910
+ * @param {(agent?: object) => object | undefined | Promise<object | undefined>} resolver - actor resolver.
911
+ * @returns {() => void} disposer.
912
+ */
913
+ registerActorResolver(resolver) {
914
+ if (typeof resolver !== 'function') {
915
+ throw new TypeError('actor resolver must be a function');
916
+ }
917
+ resolvers.add(resolver);
918
+ return () => resolvers.delete(resolver);
919
+ },
920
+ };
921
+ }
922
+
923
+ /**
924
+ * Host plugin body.
925
+ *
926
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Host context.
927
+ * @returns {void}
928
+ */
929
+ export function apply(ctx) {
930
+ const git = createGitRepository({ resolveConfig: () => resolveGitFoxConfig(ctx) });
931
+ const api = createDevOpsService(
932
+ createGitFoxClient({ resolveConfig: () => resolveGitFoxConfig(ctx) }),
933
+ git,
934
+ );
935
+ ctx.provide('infinityDevOps', api);
936
+ registerProxyRoutes(ctx);
937
+ ctx.effect(() => ctx.tools.register(createTool(ctx, api)), 'is-devops: infinity_devops tool');
938
+ ctx.effect(
939
+ () => ctx.tools.register(createGitTool({ git, resolveActor: api.resolveActor })),
940
+ 'is-devops: infinity_git tool',
941
+ );
942
+ }
943
+
944
+ /** Services required by the Host half. */
945
+ export const inject = ['infinitySettings', 'webServer', 'tools'];
946
+
947
+ /** Re-exported for tests and sibling packages that resolve this module directly. */
948
+ export { withQuery };