@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.0-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.
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +201 -21
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +232 -24
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +33 -9
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-update/SKILL.md +34 -22
|
@@ -1,20 +1,452 @@
|
|
|
1
|
-
// GitLab forge adapter
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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-
|
|
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 ?? {
|
|
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 ??
|
|
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
|
+
}
|