@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.1-beta.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.
@@ -1,20 +1,452 @@
1
- // GitLab forge adapter — stub. Reserved as the next sibling of github.mjs
2
- // so the discovery pattern stays uniform: workspaces opting into GitLab
3
- // set `workspace.forge.type: gitlab` in workspace.json and get a clear
4
- // "not implemented" error pointing at the gap, rather than silently
5
- // routing through GitHub.
1
+ // GitLab forge adapter. Wraps the `glab` CLI via an injectable spawnFn so
2
+ // tests can mock without spawning a real subprocess (mirrors the pattern
3
+ // in github.mjs / trackers/github-issues.mjs).
6
4
  //
7
- // When implemented, this adapter wraps the `glab` CLI the same way
8
- // github.mjs wraps `gh`: same method surface (prCreate, prMerge, prView,
9
- // prList, releaseView, releaseCreate, workflowRunFind, workflowRunWatch),
10
- // same spawnFn-injectable shape for testability, same error types from
11
- // interface.mjs.
12
-
13
- import { ForgeError } from './interface.mjs';
14
-
15
- export function createGitlabAdapter(config /* , options */) {
16
- throw new ForgeError(
17
- 'GitLab forge adapter is not implemented yet. Set workspace.forge.type to "github", or contribute a glab-based adapter at .claude/scripts/forges/gitlab.mjs following the shape of github.mjs.',
18
- 'NOT_IMPLEMENTED',
19
- );
5
+ // All operations target a single default `repo` resolved at construction
6
+ // time from `config.repo` (a `group/sub/project` path, any depth) or from
7
+ // the local git origin remote when `config.repo` is unset or `"auto"`.
8
+ // Per-call `repo` overrides allow targeting a different project when
9
+ // needed. A self-managed instance is selected with `config.host`; it is
10
+ // passed to glab as GITLAB_HOST (and --hostname for `glab api`), while the
11
+ // default is gitlab.com.
12
+ //
13
+ // GitLab-vs-GitHub shape notes, where the interface contract is stated in
14
+ // gh's terms (see interface.mjs):
15
+ // - MR states come back upper-cased ("OPEN", "MERGED", "CLOSED") because
16
+ // callers compare against the vocabulary gh prints; glab's JSON is
17
+ // lower-case.
18
+ // - `search` is GitLab's own syntax (a title/description substring) —
19
+ // except the `merged:>{timestamp}` form the release skill sends, which
20
+ // glab cannot express and this adapter translates into a merged-at
21
+ // filter. An unparseable timestamp throws (silently dropping the cutoff
22
+ // would list every MR ever merged), and a full page whose every row
23
+ // survives the cutoff is flagged `truncated: true` — older in-window
24
+ // MRs may sit on a page this list never fetched.
25
+ // - `workflowRunFind`/`workflowRunWatch` map onto pipelines: a GitLab
26
+ // project runs one pipeline per ref, so the `workflow` name is ignored.
27
+ // Watching polls the pipeline until it reaches a status that cannot
28
+ // change on its own — a `manual` job keeps polling, since approving it
29
+ // resumes the pipeline.
30
+ // - `releaseCreate` has no forge-generated-notes equivalent on GitLab:
31
+ // `generateNotes: true` throws NOT_SUPPORTED — callers pass
32
+ // `generateNotes: false` plus explicit `notes`.
33
+ // - `reviewDecision` is always "" (approvals surface through
34
+ // mergeStateStatus, GitLab's detailed_merge_status).
35
+
36
+ import '../../lib/require-node.mjs';
37
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
38
+ import {
39
+ PrNotFound,
40
+ ReleaseNotFound,
41
+ WorkflowNotFound,
42
+ MergeRejected,
43
+ ForgeError,
44
+ } from './interface.mjs';
45
+
46
+ const GITLAB_DEFAULT_HOST = 'gitlab.com';
47
+
48
+ // Pipeline statuses that cannot change on their own. `manual` deliberately
49
+ // reads as non-terminal here: an operator approving the manual job turns
50
+ // the pipeline running again, so a watch keeps polling past it.
51
+ const PIPELINE_TERMINAL = new Set(['success', 'failed', 'canceled', 'skipped']);
52
+
53
+ const NOT_FOUND_RE = /404|not\s+found/i;
54
+
55
+ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs = 5000, maxPolls = 720 } = {}) {
56
+ const defaultRepo = resolveRepo(config, spawnFn);
57
+ const host = typeof config?.host === 'string' && config.host ? config.host : GITLAB_DEFAULT_HOST;
58
+
59
+ function glab(args, { input } = {}) {
60
+ // GITLAB_HOST always names the resolved instance — always, because an
61
+ // exported foreign value must not leak into a gitlab.com run — and
62
+ // aims the -R/--repo slugs at it.
63
+ const env = { ...process.env, GITLAB_HOST: host };
64
+ return spawnFn('glab', args, {
65
+ input,
66
+ encoding: 'utf-8',
67
+ env,
68
+ stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
69
+ });
70
+ }
71
+
72
+ function glabOrThrow(args, opts) {
73
+ const result = glab(args, opts);
74
+ if (result.status !== 0) {
75
+ throw new Error(`glab ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
76
+ }
77
+ return result.stdout || '';
78
+ }
79
+
80
+ // `glab api` resolves its host from the CWD's git remote, not from
81
+ // GITLAB_HOST, so the resolved host is always passed explicitly.
82
+ function apiArgs(path) {
83
+ return ['api', path, '--hostname', host];
84
+ }
85
+
86
+ function repoFor(override) {
87
+ return override || defaultRepo;
88
+ }
89
+
90
+ function webUrl(repo, kind, number) {
91
+ return `https://${host}/${repo}/-/${kind}${number !== undefined ? `/${number}` : ''}`;
92
+ }
93
+
94
+ async function prCreate({ title, body = '', draft = false, base, head, repo }) {
95
+ if (!title) throw new Error('prCreate: title is required');
96
+ const target = repoFor(repo);
97
+ // glab has no --body-file: the description travels as an argv element.
98
+ const args = ['mr', 'create', '--repo', target, '--title', title, '--description', body, '--yes'];
99
+ if (draft) args.push('--draft');
100
+ if (base) args.push('--target-branch', base);
101
+ if (head) args.push('--source-branch', head);
102
+ const stdout = glabOrThrow(args).trim();
103
+ const m = stdout.match(/\/-\/merge_requests\/(\d+)/);
104
+ if (!m) throw new Error(`Could not parse MR number from glab output: ${stdout}`);
105
+ const number = parseInt(m[1], 10);
106
+ // The URL glab printed wins over the constructed one when it names
107
+ // exactly this MR — compared as a parsed URL's path, never by a
108
+ // substring test against a URL (CodeQL:
109
+ // incomplete-url-substring-sanitization); trailing punctuation is
110
+ // stripped so sentence-embedded URLs still parse.
111
+ const printedUrl = stdout.split('\n').flatMap((l) => l.match(/https?:\/\/\S+/g) ?? [])
112
+ .find((u) => {
113
+ try { return new URL(u.replace(/[),.;]+$/, '')).pathname === `/${target}/-/merge_requests/${number}`; }
114
+ catch { return false; }
115
+ });
116
+ const url = printedUrl || webUrl(target, 'merge_requests', number);
117
+ return { id: `${target}!${number}`, url, number };
118
+ }
119
+
120
+ // glab's `mr merge` defaults to --auto-merge: with a pipeline still
121
+ // running it only QUEUES the merge and exits 0, which would read as
122
+ // success here while the MR stays open (and task-pr then merges the
123
+ // workspace MR and closes the issue ahead of CI ever publishing).
124
+ // --auto-merge=false makes glab merge now or fail, and the post-merge
125
+ // view verifies the merge actually happened — glab exiting 0 is not
126
+ // proof on its own.
127
+ async function prMerge({ id, strategy = 'merge', deleteBranch = false, repo }) {
128
+ if (!id) throw new Error('prMerge: id is required');
129
+ const { number, repo: parsedRepo } = parseMrId(id, repoFor(repo));
130
+ const before = await prView({ id, repo: parsedRepo });
131
+ if (before.isDraft) {
132
+ throw new MergeRejected(id, 'MR is a draft — mark it ready before merging');
133
+ }
134
+ const args = ['mr', 'merge', String(number), '--repo', parsedRepo, '--yes', '--auto-merge=false'];
135
+ switch (strategy) {
136
+ case 'merge': break; // plain merge is glab's default
137
+ case 'squash': args.push('--squash'); break;
138
+ case 'rebase': args.push('--rebase'); break;
139
+ default: throw new Error(`prMerge: unknown strategy: ${strategy}`);
140
+ }
141
+ if (deleteBranch) args.push('--remove-source-branch');
142
+ const result = glab(args);
143
+ if (result.status !== 0) {
144
+ const stderr = (result.stderr || '').trim();
145
+ // Distinguish "not found" from "rejected" so callers can react.
146
+ if (NOT_FOUND_RE.test(stderr)) {
147
+ throw new PrNotFound(id);
148
+ }
149
+ throw new MergeRejected(id, stderr || 'glab mr merge exited non-zero');
150
+ }
151
+ const after = await prView({ id, repo: parsedRepo });
152
+ if (after.state !== 'MERGED') {
153
+ throw new MergeRejected(
154
+ id,
155
+ `MR is still ${after.state} after glab exited 0 — the merge was queued, not applied`,
156
+ );
157
+ }
158
+ return { merged: true, url: after.url };
159
+ }
160
+
161
+ async function prView({ id, repo, json }) {
162
+ if (!id) throw new Error('prView: id is required');
163
+ const { number, repo: parsedRepo } = parseMrId(id, repoFor(repo));
164
+ // glab always returns the full MR entity; the `json` field list gh
165
+ // accepts has no equivalent here and is ignored.
166
+ void json;
167
+ const result = glab(['mr', 'view', String(number), '--repo', parsedRepo, '--output', 'json']);
168
+ if (result.status !== 0) {
169
+ const stderr = (result.stderr || '').trim();
170
+ if (NOT_FOUND_RE.test(stderr)) {
171
+ throw new PrNotFound(id);
172
+ }
173
+ throw new Error(`glab mr view failed: ${stderr}`);
174
+ }
175
+ const raw = JSON.parse(result.stdout);
176
+ return {
177
+ id,
178
+ number: raw.iid,
179
+ url: raw.web_url,
180
+ state: mrState(raw.state),
181
+ title: raw.title,
182
+ mergeable: raw.has_conflicts ? 'CONFLICTING' : 'MERGEABLE',
183
+ mergeStateStatus: raw.detailed_merge_status ?? raw.merge_status ?? null,
184
+ reviewDecision: '',
185
+ headRefName: raw.source_branch,
186
+ baseRefName: raw.target_branch,
187
+ isDraft: !!raw.draft,
188
+ mergedAt: raw.merged_at,
189
+ _raw: raw,
190
+ };
191
+ }
192
+
193
+ // Listing merged MRs is how /release proves the unreleased-notes pile is
194
+ // complete rather than merely empty (gh:89). The release skill bounds the
195
+ // window with GitHub's `merged:>{timestamp}` search syntax; glab has no
196
+ // equivalent filter, so that form is translated here into merged-MRs-only,
197
+ // newest-first, plus a client-side mergedAt cutoff. Anything else passes to
198
+ // glab's own `--search` (a title/description substring) verbatim, per the
199
+ // interface's "the forge's own search syntax" contract.
200
+ //
201
+ // glab returns a single page of `limit` rows. A page that is not full, or
202
+ // one whose oldest rows fall below the cutoff, proves the window's far
203
+ // edge was reached — the list is complete. A full page whose every row
204
+ // survives the cutoff does not: older in-window MRs could sit on a page
205
+ // never fetched, and that maybe-incomplete case is flagged with a
206
+ // non-enumerable `truncated: true` on the returned array, so callers that
207
+ // just map the list never see it.
208
+ async function prList({ state = 'merged', base, head, search, limit = 100, repo }) {
209
+ const target = repoFor(repo);
210
+ const args = ['mr', 'list', '--repo', target, '--output', 'json', '--per-page', String(limit)];
211
+ let mergedAfter = null;
212
+ const searchMatch = search ? String(search).match(/^merged:>\s*(.+)$/) : null;
213
+ if (searchMatch) {
214
+ args.push('--merged', '--order', 'merged_at', '--sort', 'desc');
215
+ mergedAfter = Date.parse(searchMatch[1].trim());
216
+ // An unparseable bound throws instead of silently listing unbounded —
217
+ // the release notes assembled from this list would quietly cover the
218
+ // project's entire history.
219
+ if (Number.isNaN(mergedAfter)) {
220
+ throw new Error(`prList: merged:> needs a parseable timestamp (e.g. merged:>2026-07-01T00:00:00Z), got: ${searchMatch[1].trim()}`);
221
+ }
222
+ } else {
223
+ switch (state) {
224
+ case 'open':
225
+ case 'opened': break; // opened MRs are glab's default listing
226
+ case 'merged': args.push('--merged'); break;
227
+ case 'closed': args.push('--closed'); break;
228
+ case 'all': args.push('--all'); break;
229
+ default: throw new Error(`prList: unknown state: ${state}`);
230
+ }
231
+ if (search) args.push('--search', search);
232
+ }
233
+ if (base) args.push('--target-branch', base);
234
+ if (head) args.push('--source-branch', head);
235
+ const stdout = glabOrThrow(args).trim();
236
+ let raw = stdout ? JSON.parse(stdout) : [];
237
+ const pageFull = raw.length === limit;
238
+ let truncated = pageFull;
239
+ if (mergedAfter !== null) {
240
+ const kept = raw.filter((p) => p.merged_at && Date.parse(p.merged_at) > mergedAfter);
241
+ truncated = pageFull && kept.length === raw.length;
242
+ raw = kept;
243
+ }
244
+ const list = raw.map((p) => ({
245
+ id: p.references?.full ?? `${target}!${p.iid}`,
246
+ number: p.iid,
247
+ title: p.title,
248
+ url: p.web_url,
249
+ headRefName: p.source_branch,
250
+ baseRefName: p.target_branch,
251
+ mergedAt: p.merged_at,
252
+ state: mrState(p.state),
253
+ }));
254
+ if (truncated) Object.defineProperty(list, 'truncated', { value: true });
255
+ return list;
256
+ }
257
+
258
+ async function releaseView({ tag, repo }) {
259
+ if (!tag) throw new Error('releaseView: tag is required');
260
+ const target = repoFor(repo);
261
+ const result = glab(['release', 'view', tag, '--repo', target, '--output', 'json']);
262
+ if (result.status !== 0) {
263
+ const stderr = (result.stderr || '').trim();
264
+ if (NOT_FOUND_RE.test(stderr)) {
265
+ throw new ReleaseNotFound(tag);
266
+ }
267
+ throw new Error(`glab release view failed: ${stderr}`);
268
+ }
269
+ const raw = JSON.parse(result.stdout);
270
+ return {
271
+ tag: raw.tag_name,
272
+ name: raw.name,
273
+ url: webUrl(target, 'releases', tag),
274
+ publishedAt: raw.released_at ?? raw.created_at,
275
+ isDraft: false,
276
+ isPrerelease: false,
277
+ };
278
+ }
279
+
280
+ // GitLab releases carry whatever notes they are given; there is no
281
+ // generate-from-merged-MRs mechanism (gh:157's notes story is GitHub-only).
282
+ // generateNotes: true is therefore a typed NOT_SUPPORTED rather than a
283
+ // silently empty release; callers pass generateNotes: false with `notes`.
284
+ async function releaseCreate({ tag, target, title, generateNotes = true, notes = null, repo }) {
285
+ if (!tag) throw new Error('releaseCreate: tag is required');
286
+ if (generateNotes) {
287
+ throw new ForgeError(
288
+ `GitLab cannot generate release notes from merged MRs — pass generateNotes: false and explicit notes for ${tag}.`,
289
+ 'NOT_SUPPORTED',
290
+ );
291
+ }
292
+ const args = ['release', 'create', tag, '--repo', repoFor(repo)];
293
+ if (target) args.push('--ref', target);
294
+ if (title) args.push('--name', title);
295
+ if (notes) args.push('--notes', notes);
296
+ glabOrThrow(args);
297
+ return { url: webUrl(repoFor(repo), 'releases', tag), tag };
298
+ }
299
+
300
+ // Pipelines are GitLab's workflow runs: one per ref, so `workflow` (a
301
+ // GitHub workflow-file name) has nothing to select between.
302
+ async function workflowRunFind({ workflow, branch, repo, limit = 1 }) {
303
+ if (!workflow) throw new Error('workflowRunFind: workflow is required');
304
+ void workflow;
305
+ const target = repoFor(repo);
306
+ const args = ['ci', 'list', '--repo', target, '--output', 'json', '--per-page', String(limit)];
307
+ if (branch) args.push('--ref', branch);
308
+ const stdout = glabOrThrow(args).trim();
309
+ const pipelines = stdout ? JSON.parse(stdout) : [];
310
+ if (pipelines.length === 0) return null;
311
+ return normalizePipeline(pipelines[0]);
312
+ }
313
+
314
+ // No `glab ci watch` exists — watch polls the pipeline until it reaches a
315
+ // status that cannot change on its own. A `manual` job is not that: an
316
+ // approval resumes the pipeline, so polling continues past it up to the
317
+ // bound (an hour by default), and the timeout names what it waited on. A
318
+ // pipeline that never terminates surfaces as a plain Error, matching the
319
+ // interface's "raw failures throw Error".
320
+ async function workflowRunWatch({ runId, repo, exitStatus = false }) {
321
+ if (!runId) throw new Error('workflowRunWatch: runId is required');
322
+ const target = repoFor(repo);
323
+ const path = `projects/${encodeURIComponent(target)}/pipelines/${runId}`;
324
+ let lastStatus = null;
325
+ for (let i = 0; i < maxPolls; i += 1) {
326
+ const stdout = glabOrThrow(apiArgs(path)).trim();
327
+ let pipeline;
328
+ try {
329
+ pipeline = JSON.parse(stdout);
330
+ } catch (err) {
331
+ throw new Error(`glab api ${path} returned unparseable output: ${err.message}`);
332
+ }
333
+ lastStatus = pipeline.status;
334
+ if (PIPELINE_TERMINAL.has(pipeline.status)) {
335
+ if (!exitStatus || pipeline.status === 'success') return { exitCode: 0 };
336
+ return { exitCode: 1, stderr: `pipeline ${pipeline.status}: ${pipeline.web_url}` };
337
+ }
338
+ if (i < maxPolls - 1) await new Promise((resolve) => setTimeout(resolve, pollMs));
339
+ }
340
+ const waiting = lastStatus === 'manual'
341
+ ? ' — still waiting on a manual job (approve it in the pipeline UI)'
342
+ : '';
343
+ throw new Error(`pipeline ${runId} did not finish within ${Math.round((pollMs * maxPolls) / 60000)} minutes${waiting}`);
344
+ }
345
+
346
+ return {
347
+ prCreate,
348
+ prMerge,
349
+ prView,
350
+ prList,
351
+ releaseView,
352
+ releaseCreate,
353
+ workflowRunFind,
354
+ workflowRunWatch,
355
+ get identity() { return `gitlab:${defaultRepo}`; },
356
+ };
357
+ }
358
+
359
+ // MR state in the vocabulary gh prints (OPEN/MERGED/CLOSED) — callers
360
+ // compare against those exact strings; glab says "opened".
361
+ function mrState(state) {
362
+ const s = String(state || '').toLowerCase();
363
+ if (s === 'opened') return 'OPEN';
364
+ if (s === 'merged') return 'MERGED';
365
+ if (s === 'closed') return 'CLOSED';
366
+ return String(state || '').toUpperCase();
367
+ }
368
+
369
+ // Pipeline status, in GitLab's one-field vocabulary, split into the
370
+ // status/conclusion pair gh reports.
371
+ function normalizePipeline(raw) {
372
+ const terminal = {
373
+ success: 'success',
374
+ failed: 'failure',
375
+ canceled: 'cancelled',
376
+ skipped: 'skipped',
377
+ manual: 'action_required',
378
+ };
379
+ const running = new Set(['running']);
380
+ const queued = new Set(['created', 'pending', 'preparing', 'waiting_for_resource', 'scheduled']);
381
+ let status;
382
+ let conclusion = null;
383
+ if (raw.status in terminal) {
384
+ status = 'completed';
385
+ conclusion = terminal[raw.status];
386
+ } else if (running.has(raw.status)) {
387
+ status = 'in_progress';
388
+ } else if (queued.has(raw.status)) {
389
+ status = 'queued';
390
+ } else {
391
+ status = raw.status;
392
+ }
393
+ return {
394
+ runId: String(raw.id),
395
+ status,
396
+ conclusion,
397
+ url: raw.web_url,
398
+ branch: raw.ref,
399
+ createdAt: raw.created_at,
400
+ };
401
+ }
402
+
403
+ // "group/sub/project!NUMBER" (any group depth) or just NUMBER (defaulting
404
+ // to the adapter's project).
405
+ function parseMrId(id, fallbackRepo) {
406
+ if (typeof id === 'number') return { number: id, repo: fallbackRepo };
407
+ const m1 = String(id).match(/^(?<repo>[^!\s]+)!(?<number>\d+)$/);
408
+ if (m1) return { number: parseInt(m1.groups.number, 10), repo: m1.groups.repo };
409
+ const m2 = String(id).match(/^!?(\d+)$/);
410
+ if (m2) return { number: parseInt(m2[1], 10), repo: fallbackRepo };
411
+ throw new Error(`Unparseable MR id: ${id}`);
412
+ }
413
+
414
+ // Like github.mjs's resolveRepo, kept local so the adapter stands alone:
415
+ // gitlab.com plus the configured self-managed host, scp-style and scheme
416
+ // URLs (an explicit non-default port stays in the host, so `host` can
417
+ // carry it — gitlab.example.com:8443), nested groups at any depth,
418
+ // optional .git suffix.
419
+ const DEFAULT_PORTS = { https: 443, http: 80, ssh: 22, git: 9418 };
420
+ const SCHEME_URL_RE = /^(?<scheme>https?|ssh|git):\/\/(?:[^@/]+@)?(?<host>[^:/]+)(?::(?<port>\d+))?\/(?<path>.+)$/;
421
+
422
+ function resolveRepo(config, spawnFn) {
423
+ if (config?.repo && config.repo !== 'auto') return config.repo;
424
+ const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
425
+ if (result.status !== 0) {
426
+ throw new Error(`git remote get-url failed: ${(result.stderr || '').trim()}`);
427
+ }
428
+ const hosts = [GITLAB_DEFAULT_HOST, ...(config?.host ? [String(config.host).toLowerCase()] : [])];
429
+ const s = result.stdout.trim().replace(/\/+$/, '');
430
+ let host = null;
431
+ let path = null;
432
+ const scp = s.match(/^[^@/]+@([^:/]+):(.+)$/); // scp-style: git@host:path
433
+ if (scp) {
434
+ host = scp[1].toLowerCase();
435
+ path = scp[2];
436
+ } else {
437
+ const m = s.match(SCHEME_URL_RE);
438
+ if (m) {
439
+ const { scheme, host: h, port } = m.groups;
440
+ // A non-default port stays part of the host so it can match a
441
+ // configured host that carries one; a default port drops, so
442
+ // github.com:443-style URLs still resolve.
443
+ host = (port && Number(port) !== DEFAULT_PORTS[scheme] ? `${h}:${port}` : h).toLowerCase();
444
+ path = m.groups.path;
445
+ }
446
+ }
447
+ if (host && path && hosts.includes(host)) {
448
+ const segments = path.replace(/\.git$/, '').split('/').filter(Boolean);
449
+ if (segments.length >= 2) return segments.join('/');
450
+ }
451
+ throw new Error(`Cannot parse GitLab remote: ${result.stdout.trim()}`);
20
452
  }
@@ -21,18 +21,26 @@
21
21
  // → [{ id, number, title, url, headRefName, baseRefName, mergedAt, state }]
22
22
  // `base`/`head` filter by target/source branch (e.g. the open PR for a
23
23
  // task branch); `search` passes through the forge's own search syntax
24
- // (e.g. 'merged:>2026-01-01'), so callers can bound a window without
25
- // this interface growing a date vocabulary.
24
+ // (e.g. 'merged:>2026-01-01T00:00:00Z'), so callers can bound a window
25
+ // without this interface growing a date vocabulary. Two guarantees ride
26
+ // on that: an adapter translating `merged:>X` client-side (the GitLab
27
+ // adapter does — pass a full timestamp) throws on an unparseable X
28
+ // rather than silently listing unbounded, and when a full page means
29
+ // older results may exist beyond it the returned array carries a
30
+ // non-enumerable `truncated: true` (callers that only map the list
31
+ // never see it).
26
32
  // releaseView({ tag, repo? })
27
33
  // → { tag, url, name, publishedAt }
28
34
  // throws ReleaseNotFound if the tag has no release
29
- // releaseCreate({ tag, target?, title?, generateNotes = true, repo? })
35
+ // releaseCreate({ tag, target?, title?, generateNotes = true, notes?, repo? })
30
36
  // → { url, tag }
31
37
  // target: commitish the tag points at (default: the repo's default
32
38
  // branch head); title: release name (default: the tag)
33
39
  // generateNotes: when true (the default) the forge generates the
34
40
  // release notes from merged PRs — this is the only notes mechanism
35
- // the workspace ships.
41
+ // the workspace ships. GitLab has no such generation; its adapter
42
+ // throws NOT_SUPPORTED for generateNotes: true and takes the notes
43
+ // text via `notes` with generateNotes: false.
36
44
  // workflowRunFind({ workflow, branch, repo?, limit = 1 })
37
45
  // → { runId, status, conclusion, url } | null
38
46
  // workflowRunWatch({ runId, repo?, exitStatus = false })
@@ -49,8 +57,10 @@
49
57
  // adapter-detectable failures. Raw spawn failures throw `Error`.
50
58
 
51
59
  import '../../lib/require-node.mjs';
60
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
52
61
  import { createGithubAdapter } from './github.mjs';
53
62
  import { createGitlabAdapter } from './gitlab.mjs';
63
+ import { parseForgeRemote } from '../merge-mode.mjs';
54
64
 
55
65
  export class ForgeError extends Error {
56
66
  constructor(message, code) {
@@ -100,6 +110,18 @@ export class MergeRejected extends ForgeError {
100
110
  // "behave as you always have." A workspace that wants to opt out of
101
111
  // forge operations entirely should set `workspace.forge: false`;
102
112
  // callers passing `false` will get a no-op throw on every method.
113
+ //
114
+ // With no explicit `type`, the adapter is picked from the repo's host: the
115
+ // origin remote is parsed, and a gitlab.com (or configured self-managed
116
+ // `host`) origin selects GitLab, anything else stays GitHub. An explicit
117
+ // `repo` slug does not opt out of that — callers like /release pass the
118
+ // repo's own origin-derived slug, so the origin remains the right signal
119
+ // (a GitLab repo must not land on the gh adapter because its slug alone
120
+ // names no host). Only a workspace with no origin at all — or an
121
+ // unparseable one — keeps the GitHub default, and the adapter then
122
+ // surfaces the git failure itself. This is also what lets one workspace
123
+ // mix GitHub and GitLab repos: each repo's origin names where its PRs
124
+ // live.
103
125
  export function createForge(config, options = {}) {
104
126
  if (config === false) {
105
127
  throw new ForgeError(
@@ -107,14 +129,14 @@ export function createForge(config, options = {}) {
107
129
  'FORGE_DISABLED',
108
130
  );
109
131
  }
110
- const resolved = config ?? { type: 'github' };
132
+ const resolved = config ?? {};
111
133
  if (typeof resolved !== 'object') {
112
134
  throw new ForgeError(
113
135
  `Invalid workspace.forge config: expected object, got ${typeof resolved}`,
114
136
  'INVALID_CONFIG',
115
137
  );
116
138
  }
117
- const type = resolved.type ?? 'github';
139
+ const type = resolved.type ?? inferForgeType(resolved, options);
118
140
  switch (type) {
119
141
  case 'github':
120
142
  return createGithubAdapter(resolved, options);
@@ -124,3 +146,14 @@ export function createForge(config, options = {}) {
124
146
  throw new ForgeError(`Unknown forge type: ${type}`, 'UNKNOWN_TYPE');
125
147
  }
126
148
  }
149
+
150
+ // Resolve the adapter type from where the repo actually lives. The remote
151
+ // is read once here; the adapter re-reads it when resolving its own repo,
152
+ // keeping the two consistent (same spawnFn, same origin).
153
+ function inferForgeType(config, options) {
154
+ const spawnFn = options.spawnFn ?? nodeSpawnSync;
155
+ const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
156
+ if (result.status !== 0) return 'github'; // the adapter surfaces the failure
157
+ const hosts = config?.host ? [config.host] : [];
158
+ return parseForgeRemote(String(result.stdout || '').trim(), { hosts })?.forge ?? 'github';
159
+ }