@infinisys/is-devops 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Thin, dependency-free GitFox REST client used by the DevOps Host half and
3
+ * its single agent tool.
4
+ *
5
+ * The client never stores a token: every operation resolves the connection
6
+ * through `infinitySettings` so a settings save reaches the very next call.
7
+ */
8
+
9
+ import { request as httpRequest } from 'node:http';
10
+ import { request as httpsRequest } from 'node:https';
11
+
12
+ import { DEFAULT_AGENT_ACTOR, withBotName } from './bot-identity.js';
13
+
14
+ /** Error carrying an HTTP status and a stable code. */
15
+ export class GitFoxError extends Error {
16
+ /**
17
+ * @param {string} message - human-readable detail.
18
+ * @param {{ status?: number, code?: string }} [options] - error metadata.
19
+ */
20
+ constructor(message, options = {}) {
21
+ super(message);
22
+ this.name = 'GitFoxError';
23
+ this.status = options.status ?? 0;
24
+ this.code = options.code ?? 'gitfox-error';
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Split a `namespace/name` project reference.
30
+ *
31
+ * @param {unknown} value - user-supplied project reference.
32
+ * @returns {{ namespace: string, name: string }} decoded project parts.
33
+ * @throws {GitFoxError} when the reference is not exactly two non-empty segments.
34
+ */
35
+ export function parseProject(value) {
36
+ const text = typeof value === 'string' ? value.trim() : '';
37
+ const parts = text.split('/').filter((part) => part !== '');
38
+ if (parts.length !== 2) {
39
+ throw new GitFoxError('project must use the "namespace/name" form', { code: 'invalid-project' });
40
+ }
41
+ return { namespace: parts[0], name: parts[1] };
42
+ }
43
+
44
+ /**
45
+ * Build the API path of one project.
46
+ *
47
+ * @param {unknown} project - `namespace/name` reference.
48
+ * @returns {string} encoded API path.
49
+ */
50
+ export function projectPath(project) {
51
+ const { namespace, name } = parseProject(project);
52
+ return `/api/v1/projects/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`;
53
+ }
54
+
55
+ /**
56
+ * Append defined query parameters.
57
+ *
58
+ * @param {string} path - path with no query string.
59
+ * @param {Record<string, unknown>} [query] - optional parameters.
60
+ * @returns {string} path with a query string when any value is defined.
61
+ */
62
+ export function withQuery(path, query = {}) {
63
+ const params = new URLSearchParams();
64
+ for (const [key, value] of Object.entries(query)) {
65
+ if (value === undefined || value === null || value === '') continue;
66
+ params.set(key, String(value));
67
+ }
68
+ const suffix = params.toString();
69
+ return suffix === '' ? path : `${path}?${suffix}`;
70
+ }
71
+
72
+ /**
73
+ * Resolve the attribution of one creation request. Host automation is a bot by
74
+ * default; an explicit `botName: null` opts out (the browser panel never uses
75
+ * this client, so human actions still stay human).
76
+ *
77
+ * @param {{ botName?: string | null }} options - client options.
78
+ * @returns {string | null} bot name or null.
79
+ */
80
+ function attribution(options) {
81
+ return Object.hasOwn(options, 'botName') ? options.botName : DEFAULT_AGENT_ACTOR;
82
+ }
83
+
84
+ /**
85
+ * Read a bounded request body as bytes.
86
+ *
87
+ * @param {import('node:http').IncomingMessage} req - incoming request.
88
+ * @param {number} [maxBytes] - maximum accepted bytes.
89
+ * @returns {Promise<Buffer>} body bytes.
90
+ */
91
+ export async function readRequestBody(req, maxBytes = 5 * 1024 * 1024) {
92
+ const chunks = [];
93
+ let size = 0;
94
+ for await (const chunk of req) {
95
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
96
+ size += bytes.length;
97
+ if (size > maxBytes) {
98
+ throw new GitFoxError(`request body exceeds ${maxBytes} bytes`, {
99
+ status: 413,
100
+ code: 'payload-too-large',
101
+ });
102
+ }
103
+ chunks.push(bytes);
104
+ }
105
+ return Buffer.concat(chunks);
106
+ }
107
+
108
+ const REQUEST_TIMEOUT_MS = 20_000;
109
+
110
+ /** @returns {GitFoxError} the error raised when no token is configured. */
111
+ function notConnectedError() {
112
+ return new GitFoxError(
113
+ 'GitFox is not connected. Open Settings → Infinity System and add an access token.',
114
+ { status: 503, code: 'not-configured' },
115
+ );
116
+ }
117
+
118
+ /**
119
+ * @param {string} baseUrl - configured GitFox base URL.
120
+ * @param {string} path - API path beginning with `/api/v1/`.
121
+ * @returns {string} absolute request URL.
122
+ */
123
+ function joinUrl(baseUrl, path) {
124
+ return `${baseUrl.replace(/\/+$/, '')}${path}`;
125
+ }
126
+
127
+ /**
128
+ * Build the abort signal, combining an optional caller signal with the timeout.
129
+ *
130
+ * @param {AbortSignal | undefined} signal - optional caller signal.
131
+ * @returns {AbortSignal} combined signal.
132
+ */
133
+ function buildSignal(signal) {
134
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
135
+ if (signal === undefined) return timeout;
136
+ return AbortSignal.any([signal, timeout]);
137
+ }
138
+
139
+ /**
140
+ * @param {{ token: string }} config - resolved configuration.
141
+ * @param {{ method?: string, body?: unknown, signal?: AbortSignal }} options - request options.
142
+ * @returns {RequestInit} fetch options.
143
+ */
144
+ function buildRequestOptions(config, options) {
145
+ const body = options.body === undefined ? undefined : JSON.stringify(options.body);
146
+ const headers = {
147
+ accept: 'application/json',
148
+ authorization: `Bearer ${config.token}`,
149
+ };
150
+ if (body !== undefined) {
151
+ headers['content-type'] = 'application/json';
152
+ }
153
+ return {
154
+ method: options.method ?? 'GET',
155
+ headers,
156
+ body,
157
+ signal: buildSignal(options.signal),
158
+ };
159
+ }
160
+
161
+ /** Environment variables a runtime may route outbound requests through. */
162
+ const PROXY_VARIABLES = Object.freeze([
163
+ 'HTTPS_PROXY',
164
+ 'https_proxy',
165
+ 'HTTP_PROXY',
166
+ 'http_proxy',
167
+ 'ALL_PROXY',
168
+ 'all_proxy',
169
+ ]);
170
+
171
+ /**
172
+ * The proxy configured for this process, when any.
173
+ *
174
+ * @returns {URL | undefined} proxy URL.
175
+ */
176
+ function configuredProxy() {
177
+ for (const name of PROXY_VARIABLES) {
178
+ const value = process.env[name];
179
+ if (typeof value !== 'string' || value.trim() === '') continue;
180
+ try {
181
+ return new URL(value.trim());
182
+ } catch {
183
+ continue;
184
+ }
185
+ }
186
+ return undefined;
187
+ }
188
+
189
+ /**
190
+ * Whether a fetch failure is a refused/unresolvable connection to the proxy.
191
+ *
192
+ * @param {unknown} error - thrown fetch error.
193
+ * @returns {boolean} true when retrying without the proxy is worthwhile.
194
+ */
195
+ function isProxyConnectionFailure(error) {
196
+ if (configuredProxy() === undefined) return false;
197
+ const code = error instanceof Error ? error.cause?.code : undefined;
198
+ return code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH' || code === 'ECONNRESET';
199
+ }
200
+
201
+ /**
202
+ * One minimal HTTP(S) request that ignores the process proxy environment.
203
+ *
204
+ * Used only to retry a request whose proxied transport was refused; the global
205
+ * `fetch` keeps its configured proxy for every other caller.
206
+ *
207
+ * @param {string} url - absolute URL.
208
+ * @param {{ method?: string, headers?: Record<string, string>, body?: string, signal?: AbortSignal }} init - request init.
209
+ * @returns {Promise<{ ok: boolean, status: number, text: () => Promise<string> }>} response.
210
+ */
211
+ function directFetch(url, init = {}) {
212
+ return new Promise((resolve, reject) => {
213
+ let target;
214
+ try {
215
+ target = new URL(url);
216
+ } catch (error) {
217
+ reject(error);
218
+ return;
219
+ }
220
+ const client = target.protocol === 'https:' ? httpsRequest : httpRequest;
221
+ const request = client(target, { method: init.method ?? 'GET', headers: init.headers }, (response) => {
222
+ const chunks = [];
223
+ response.on('data', (chunk) => chunks.push(chunk));
224
+ response.on('end', () => {
225
+ const text = Buffer.concat(chunks).toString('utf8');
226
+ resolve({
227
+ ok: response.statusCode !== undefined && response.statusCode >= 200 && response.statusCode < 300,
228
+ status: response.statusCode ?? 0,
229
+ text: async () => text,
230
+ });
231
+ });
232
+ });
233
+ request.on('error', reject);
234
+ if (init.signal !== undefined) {
235
+ if (init.signal.aborted) {
236
+ request.destroy();
237
+ return;
238
+ }
239
+ init.signal.addEventListener('abort', () => request.destroy(new Error('request aborted')), {
240
+ once: true,
241
+ });
242
+ }
243
+ if (init.body !== undefined) request.write(init.body);
244
+ request.end();
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Format the failing socket address of a fetch cause.
250
+ *
251
+ * @param {any} cause - fetch error cause.
252
+ * @returns {string} ` (host:port)` or an empty string.
253
+ */
254
+ function describeCause(cause) {
255
+ const address = cause?.address ?? cause?.hostname ?? cause?.host;
256
+ if (address === undefined) return '';
257
+ const port = cause?.port;
258
+ return ` (${address}${port === undefined || port === null ? '' : `:${port}`})`;
259
+ }
260
+
261
+ /**
262
+ * Hint for a request that failed on its environment proxy.
263
+ *
264
+ * @param {unknown} error - thrown fetch error.
265
+ * @returns {string} hint text or an empty string.
266
+ */
267
+ function proxyHint(error) {
268
+ const proxy = configuredProxy();
269
+ if (proxy === undefined || !isProxyConnectionFailure(error)) return '';
270
+ return ` The request was routed through the proxy ${proxy.href} from the environment; start it or unset HTTPS_PROXY/HTTP_PROXY/NODE_USE_ENV_PROXY.`;
271
+ }
272
+
273
+ /**
274
+ * Host of the requested URL, for the error message.
275
+ *
276
+ * @param {string | undefined} url - requested URL.
277
+ * @returns {string} ` at host` or an empty string.
278
+ */
279
+ function hostOf(url) {
280
+ if (url === undefined) return '';
281
+ try {
282
+ return ` at ${new URL(url).host}`;
283
+ } catch {
284
+ return '';
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Translate a failed fetch into a GitFoxError, keeping the real cause.
290
+ *
291
+ * @param {unknown} error - thrown fetch error.
292
+ * @param {string} [url] - requested URL.
293
+ * @returns {GitFoxError} mapped error.
294
+ */
295
+ function mapFetchError(error, url) {
296
+ if (error instanceof Error && error.name === 'TimeoutError') {
297
+ return new GitFoxError('GitFox did not answer within 20 seconds.', {
298
+ status: 504,
299
+ code: 'timeout',
300
+ });
301
+ }
302
+ const cause = error instanceof Error ? error.cause : undefined;
303
+ const detail = cause?.message ?? (error instanceof Error ? error.message : String(error));
304
+ return new GitFoxError(
305
+ `Cannot reach GitFox${hostOf(url)}: ${detail}${describeCause(cause)}.${proxyHint(error)}`,
306
+ { status: 502, code: 'unreachable' },
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Decode a response body.
312
+ *
313
+ * @param {string} raw - raw response text.
314
+ * @param {boolean} keepRaw - whether a non-JSON body is returned as a string.
315
+ * @returns {any} parsed payload, raw text, or `undefined`.
316
+ */
317
+ function decodePayload(raw, keepRaw) {
318
+ if (raw === '') return undefined;
319
+ try {
320
+ return JSON.parse(raw);
321
+ } catch {
322
+ return keepRaw ? raw : undefined;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Extract the most useful error message from a rejected response.
328
+ *
329
+ * @param {any} payload - decoded response payload.
330
+ * @param {string} raw - raw response text.
331
+ * @param {number} status - HTTP status code.
332
+ * @returns {string} human-readable message.
333
+ */
334
+ function rejectionMessage(payload, raw, status) {
335
+ if (payload && typeof payload === 'object') {
336
+ if (typeof payload.message === 'string') return payload.message;
337
+ if (typeof payload.error === 'string') return payload.error;
338
+ }
339
+ return raw.slice(0, 300) || `GitFox answered HTTP ${status}.`;
340
+ }
341
+
342
+ /**
343
+ * @param {Response} response - failed response.
344
+ * @param {any} payload - decoded response payload.
345
+ * @param {string} raw - raw response text.
346
+ * @returns {GitFoxError} rejection error.
347
+ */
348
+ function rejectionError(response, payload, raw) {
349
+ return new GitFoxError(rejectionMessage(payload, raw, response.status), {
350
+ status: response.status,
351
+ code: response.status === 401 ? 'unauthorized' : 'gitfox-rejected',
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Create a GitFox API client bound to a live configuration resolver.
357
+ *
358
+ * @param {{ resolveConfig: () => Promise<{ baseUrl: string, token: string }>, fetchImpl?: typeof fetch }} options - wiring.
359
+ * @returns {object} client with one method per supported DevOps operation.
360
+ */
361
+ export function createGitFoxClient({ resolveConfig, fetchImpl = fetch, directFetchImpl }) {
362
+ const direct = directFetchImpl ?? (fetchImpl === globalThis.fetch ? directFetch : undefined);
363
+ /**
364
+ * Execute one GitFox request.
365
+ *
366
+ * @param {string} path - API path beginning with `/api/v1/`.
367
+ * @param {{ method?: string, body?: unknown, signal?: AbortSignal, raw?: boolean }} [options] - request options.
368
+ * @returns {Promise<any>} parsed JSON body, or `undefined` for an empty response.
369
+ */
370
+ async function request(path, options = {}) {
371
+ const config = await resolveConfig();
372
+ if (config.token === '') {
373
+ throw notConnectedError();
374
+ }
375
+ const url = joinUrl(config.baseUrl, path);
376
+ let response;
377
+ try {
378
+ response = await fetchImpl(url, buildRequestOptions(config, options));
379
+ } catch (error) {
380
+ // A dead proxy must not take GitFox down with it: retry the same request
381
+ // once without the environment proxy, then report the original cause.
382
+ if (direct === undefined || !isProxyConnectionFailure(error)) throw mapFetchError(error, url);
383
+ try {
384
+ response = await direct(url, buildRequestOptions(config, options));
385
+ } catch {
386
+ throw mapFetchError(error, url);
387
+ }
388
+ }
389
+ const raw = await response.text();
390
+ const payload = decodePayload(raw, options.raw === true);
391
+ if (!response.ok) {
392
+ throw rejectionError(response, payload, raw);
393
+ }
394
+ return payload;
395
+ }
396
+
397
+ /**
398
+ * Execute one public request. `/api/v1/config` advertises clone prefixes and
399
+ * must answer before a token exists, so discovery cannot require one.
400
+ *
401
+ * @param {string} path - API path beginning with `/api/v1/`.
402
+ * @param {{ signal?: AbortSignal }} [options] - request options.
403
+ * @returns {Promise<any>} parsed JSON body.
404
+ */
405
+ async function publicRequest(path, options = {}) {
406
+ const config = await resolveConfig();
407
+ const url = joinUrl(config.baseUrl, path);
408
+ const init = {
409
+ method: 'GET',
410
+ headers: { accept: 'application/json' },
411
+ signal: buildSignal(options.signal),
412
+ };
413
+ let response;
414
+ try {
415
+ response = await fetchImpl(url, init);
416
+ } catch (error) {
417
+ if (direct === undefined || !isProxyConnectionFailure(error)) throw mapFetchError(error, url);
418
+ try {
419
+ response = await direct(url, init);
420
+ } catch {
421
+ throw mapFetchError(error, url);
422
+ }
423
+ }
424
+ const raw = await response.text();
425
+ const payload = decodePayload(raw, false);
426
+ if (!response.ok) {
427
+ throw rejectionError(response, payload, raw);
428
+ }
429
+ return payload;
430
+ }
431
+
432
+ return {
433
+ request,
434
+ /** @returns {Promise<object>} public GitFox server configuration. */
435
+ getConfig: () => publicRequest('/api/v1/config'),
436
+ /** @returns {Promise<unknown>} the authenticated GitFox user. */
437
+ me: () => request('/api/v1/auth/me'),
438
+ /**
439
+ * @param {{ page?: number, per_page?: number }} [query] - pagination.
440
+ * @returns {Promise<unknown[]>} visible projects.
441
+ */
442
+ listProjects: (query = {}) => request(withQuery('/api/v1/projects', query)),
443
+ /**
444
+ * @param {unknown} project - `namespace/name`.
445
+ * @param {{ state?: string, page?: number, per_page?: number }} [query] - filters.
446
+ * @returns {Promise<unknown>} paginated issue list.
447
+ */
448
+ listIssues: (project, query = {}) =>
449
+ request(withQuery(`${projectPath(project)}/issues`, query)),
450
+ /**
451
+ * @param {unknown} project - `namespace/name`.
452
+ * @param {number} iid - project-scoped issue id.
453
+ * @returns {Promise<unknown>} issue detail.
454
+ */
455
+ getIssue: (project, iid) => request(`${projectPath(project)}/issues/${encodeURIComponent(iid)}`),
456
+ /**
457
+ * @param {unknown} project - `namespace/name`.
458
+ * @param {{ title: string, description?: string, labels?: string[] }} body - issue fields.
459
+ * @param {{ botName?: string }} [options] - attribution; a bot name writes `bot_name`.
460
+ * @returns {Promise<unknown>} created issue.
461
+ */
462
+ createIssue: (project, body, options = {}) =>
463
+ request(`${projectPath(project)}/issues`, {
464
+ method: 'POST',
465
+ body: withBotName(body, attribution(options)),
466
+ }),
467
+ /**
468
+ * @param {unknown} project - `namespace/name`.
469
+ * @param {number} iid - project-scoped issue id.
470
+ * @param {object} body - mutable issue fields, including `state`.
471
+ * @returns {Promise<unknown>} updated issue.
472
+ */
473
+ updateIssue: (project, iid, body) =>
474
+ request(`${projectPath(project)}/issues/${encodeURIComponent(iid)}`, { method: 'PUT', body }),
475
+ /**
476
+ * @param {unknown} project - `namespace/name`.
477
+ * @param {number} iid - project-scoped issue id.
478
+ * @returns {Promise<unknown[]>} issue comments in creation order.
479
+ */
480
+ listIssueNotes: (project, iid) =>
481
+ request(`${projectPath(project)}/issues/${encodeURIComponent(iid)}/notes`),
482
+ /**
483
+ * GitFox stores issue comments with a `body` field; merge-request comments
484
+ * use `content`. Both are comments to the caller, so the client keeps one
485
+ * shape and maps it per resource.
486
+ *
487
+ * @param {unknown} project - `namespace/name`.
488
+ * @param {number} iid - project-scoped issue id.
489
+ * @param {{ body: string }} note - comment text.
490
+ * @returns {Promise<unknown>} created note.
491
+ */
492
+ addIssueNote: (project, iid, note) =>
493
+ request(`${projectPath(project)}/issues/${encodeURIComponent(iid)}/notes`, {
494
+ method: 'POST',
495
+ body: { body: note.body },
496
+ }),
497
+ /**
498
+ * @param {unknown} project - `namespace/name`.
499
+ * @param {{ status?: string, page?: number, per_page?: number }} [query] - filters.
500
+ * @returns {Promise<unknown[]>} merge requests.
501
+ */
502
+ listMergeRequests: (project, query = {}) =>
503
+ request(withQuery(`${projectPath(project)}/merge_requests`, query)),
504
+ /**
505
+ * @param {unknown} project - `namespace/name`.
506
+ * @param {{ title: string, source_branch: string, target_branch: string, description?: string, is_draft?: boolean }} body - merge request fields.
507
+ * @param {{ botName?: string }} [options] - attribution; a bot name writes `bot_name`.
508
+ * @returns {Promise<unknown>} created merge request.
509
+ */
510
+ createMergeRequest: (project, body, options = {}) =>
511
+ request(`${projectPath(project)}/merge_requests`, {
512
+ method: 'POST',
513
+ body: withBotName(body, attribution(options)),
514
+ }),
515
+ /**
516
+ * @param {unknown} project - `namespace/name`.
517
+ * @param {number} iid - project-scoped merge request id.
518
+ * @param {{ title?: string, description?: string, state?: 'open' | 'closed' }} body - fields to change.
519
+ * @returns {Promise<unknown>} updated merge request.
520
+ */
521
+ updateMergeRequest: (project, iid, body) =>
522
+ request(`${projectPath(project)}/merge_requests/${encodeURIComponent(iid)}`, {
523
+ method: 'PUT',
524
+ body,
525
+ }),
526
+ /**
527
+ * @param {unknown} project - `namespace/name`.
528
+ * @param {number} iid - project-scoped merge request id.
529
+ * @param {{ squash?: boolean, delete_source_branch?: boolean }} [body] - merge options.
530
+ * @returns {Promise<unknown>} merge result.
531
+ */
532
+ mergeMergeRequest: (project, iid, body = {}) =>
533
+ request(`${projectPath(project)}/merge_requests/${encodeURIComponent(iid)}/merge`, {
534
+ method: 'PUT',
535
+ body,
536
+ }),
537
+ /**
538
+ * @param {unknown} project - `namespace/name`.
539
+ * @param {number} iid - project-scoped merge request id.
540
+ * @returns {Promise<unknown[]>} merge-request comments in creation order.
541
+ */
542
+ listMergeRequestNotes: (project, iid) =>
543
+ request(`${projectPath(project)}/merge_requests/${encodeURIComponent(iid)}/notes`),
544
+ /**
545
+ * @param {unknown} project - `namespace/name`.
546
+ * @param {number} iid - project-scoped merge request id.
547
+ * @param {{ body: string }} note - comment text.
548
+ * @returns {Promise<unknown>} created comment.
549
+ */
550
+ addMergeRequestNote: (project, iid, note) =>
551
+ request(`${projectPath(project)}/merge_requests/${encodeURIComponent(iid)}/notes`, {
552
+ method: 'POST',
553
+ body: { content: note.body },
554
+ }),
555
+ /**
556
+ * @param {unknown} project - `namespace/name`.
557
+ * @returns {Promise<unknown[]>} branches with their head commit.
558
+ */
559
+ listBranches: (project) => request(`${projectPath(project)}/repository/branches`),
560
+ /**
561
+ * @param {unknown} project - `namespace/name`.
562
+ * @param {{ name: string, ref: string }} body - new branch and source ref.
563
+ * @returns {Promise<unknown>} created branch.
564
+ */
565
+ createBranch: (project, body) =>
566
+ request(`${projectPath(project)}/repository/branches`, {
567
+ method: 'POST',
568
+ body: { name: body.name, ref_name: body.ref },
569
+ }),
570
+ /**
571
+ * @param {unknown} project - `namespace/name`.
572
+ * @param {string} branch - branch name; slashes allowed.
573
+ * @returns {Promise<undefined>} no content.
574
+ */
575
+ deleteBranch: (project, branch) =>
576
+ request(`${projectPath(project)}/repository/branches/${encodeURIComponent(branch)}`, {
577
+ method: 'DELETE',
578
+ }),
579
+ /**
580
+ * @param {unknown} project - `namespace/name`.
581
+ * @param {{ ref?: string, page?: number, per_page?: number }} [query] - revision and paging.
582
+ * @returns {Promise<unknown[]>} commits.
583
+ */
584
+ listCommits: (project, query = {}) =>
585
+ request(
586
+ withQuery(`${projectPath(project)}/repository/commits`, {
587
+ ref_name: query.ref,
588
+ page: query.page,
589
+ per_page: query.per_page,
590
+ }),
591
+ ),
592
+ /**
593
+ * @param {unknown} project - `namespace/name`.
594
+ * @param {string} sha - commit id.
595
+ * @returns {Promise<unknown>} commit detail.
596
+ */
597
+ getCommit: (project, sha) =>
598
+ request(`${projectPath(project)}/repository/commits/${encodeURIComponent(sha)}`),
599
+ };
600
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * GitFox project discovery.
3
+ *
4
+ * GitFox publishes its clone URL prefixes at `GET /api/v1/config`. The
5
+ * workspace's `origin` remote therefore names the project: strip the advertised
6
+ * prefix and the trailing `.git`, and the remaining two path segments are the
7
+ * canonical `namespace/name` reference.
8
+ */
9
+
10
+ const PROJECT_SEGMENT = /^[^\s/]+$/;
11
+
12
+ /**
13
+ * Normalize one advertised clone prefix for comparison.
14
+ *
15
+ * @param {unknown} value - prefix from the GitFox config endpoint.
16
+ * @returns {string | undefined} prefix without trailing slashes.
17
+ */
18
+ function clonePrefix(value) {
19
+ if (typeof value !== 'string') return undefined;
20
+ const prefix = value.trim().replace(/\/+$/, '');
21
+ return prefix === '' ? undefined : prefix;
22
+ }
23
+
24
+ /**
25
+ * Derive the GitFox project from one clone URL and the server config.
26
+ *
27
+ * @param {unknown} remoteUrl - `git remote get-url origin` output.
28
+ * @param {{ ssh_clone_url_prefix?: unknown, http_clone_url_prefix?: unknown }} config - GitFox server config.
29
+ * @returns {string | null} `namespace/name`, or null when this is not a GitFox remote.
30
+ */
31
+ export function projectFromCloneUrl(remoteUrl, config) {
32
+ if (typeof remoteUrl !== 'string') return null;
33
+ const url = remoteUrl.trim();
34
+ if (url === '') return null;
35
+ const prefixes = [clonePrefix(config?.ssh_clone_url_prefix), clonePrefix(config?.http_clone_url_prefix)].filter(
36
+ (prefix) => prefix !== undefined,
37
+ );
38
+ const prefix = prefixes.find((entry) => url.startsWith(entry));
39
+ if (prefix === undefined) return null;
40
+ const rest = url
41
+ .slice(prefix.length)
42
+ .replace(/^\/+/, '')
43
+ .replace(/\/+$/, '')
44
+ .replace(/\.git$/i, '')
45
+ .replace(/\/+$/, '');
46
+ const parts = rest.split('/');
47
+ if (parts.length !== 2 || parts.some((part) => !PROJECT_SEGMENT.test(part) || part === '.' || part === '..')) {
48
+ return null;
49
+ }
50
+ return parts.join('/');
51
+ }
52
+
53
+ /**
54
+ * Explain why discovery produced no project.
55
+ *
56
+ * @param {string} reason - stable reason id.
57
+ * @param {string | null} remoteUrl - observed remote.
58
+ * @returns {{ project: null, remoteUrl: string | null, reason: string }} discovery result.
59
+ */
60
+ export function undiscovered(reason, remoteUrl = null) {
61
+ return { project: null, remoteUrl, reason };
62
+ }
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Infinity DevOps",
4
+ "description": "GitFox issues, merge requests, commits and branches in one panel, with an infinity_devops agent tool for the Fabric AI team."
5
+ },
6
+ "translate": "Translate"
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Infinity DevOps",
4
+ "description": "在 Harness 面板中管理 GitFox 的议题、合并请求、提交与分支,并为 Fabric AI Team 提供 infinity_devops 工具。"
5
+ },
6
+ "translate": "翻译"
7
+ }