@remogram/provider-gitlab-api 0.1.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.
Files changed (2) hide show
  1. package/index.js +318 -0
  2. package/package.json +27 -0
package/index.js ADDED
@@ -0,0 +1,318 @@
1
+ import {
2
+ fetchJson,
3
+ sanitizeField,
4
+ sanitizeUrl,
5
+ assertGitRef,
6
+ assertGitRemote,
7
+ gitRevParse,
8
+ gitCurrentBranch,
9
+ gitAheadBehind,
10
+ ERROR_CODES,
11
+ forgeError,
12
+ } from '@remogram/core';
13
+
14
+ const PUBLIC_GITLAB_HOST = 'gitlab.com';
15
+ const PUBLIC_GITLAB_API = 'https://gitlab.com/api/v4';
16
+ const AUTH_CAPABILITIES = [
17
+ 'repo_status',
18
+ 'ref_compare',
19
+ 'pr_status',
20
+ 'pr_checks',
21
+ 'merge_plan',
22
+ 'sync_plan',
23
+ ];
24
+ const STRUCTURED_COMMANDS = AUTH_CAPABILITIES.map((name) => ({ name, implemented: true }));
25
+
26
+ export function gitlabToken() {
27
+ return process.env.GITLAB_TOKEN || null;
28
+ }
29
+
30
+ export function requireToken() {
31
+ const token = gitlabToken();
32
+ if (!token) {
33
+ throw Object.assign(new Error('GITLAB_TOKEN not set'), {
34
+ forgeError: forgeError(ERROR_CODES.UNAUTHENTICATED_PROVIDER, 'GITLAB_TOKEN not set'),
35
+ });
36
+ }
37
+ return token;
38
+ }
39
+
40
+ function configuredHost(config) {
41
+ if (!config.baseUrl) return null;
42
+ try {
43
+ return new URL(config.baseUrl).host;
44
+ } catch {
45
+ throw Object.assign(new Error('Invalid baseUrl for gitlab-api'), {
46
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'Invalid baseUrl for gitlab-api provider'),
47
+ });
48
+ }
49
+ }
50
+
51
+ function configOrigin(config) {
52
+ if (!config.baseUrl) return null;
53
+ try {
54
+ return new URL(config.baseUrl).origin;
55
+ } catch {
56
+ throw Object.assign(new Error('Invalid baseUrl for gitlab-api'), {
57
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'Invalid baseUrl for gitlab-api provider'),
58
+ });
59
+ }
60
+ }
61
+
62
+ export function apiBase(config, parsed = {}) {
63
+ const remoteHost = parsed.host || configuredHost(config);
64
+ if (!remoteHost) {
65
+ throw Object.assign(new Error('remote host required for gitlab-api'), {
66
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'remote host required for gitlab-api provider'),
67
+ });
68
+ }
69
+
70
+ const host = remoteHost.toLowerCase();
71
+ const configured = configuredHost(config);
72
+ if (host === PUBLIC_GITLAB_HOST) {
73
+ if (configured && configured.toLowerCase() !== PUBLIC_GITLAB_HOST) {
74
+ const message = 'gitlab.com remotes may use only https://gitlab.com/api/v4 for API requests';
75
+ throw Object.assign(new Error(message), {
76
+ forgeError: forgeError(ERROR_CODES.UNTRUSTED_BASE_URL, message),
77
+ });
78
+ }
79
+ return PUBLIC_GITLAB_API;
80
+ }
81
+
82
+ if (configured && configured.toLowerCase() !== host) {
83
+ const message = `GitLab API host must match remote host ${remoteHost}`;
84
+ throw Object.assign(new Error(message), {
85
+ forgeError: forgeError(ERROR_CODES.UNTRUSTED_BASE_URL, message),
86
+ });
87
+ }
88
+
89
+ const origin = configOrigin(config) || `https://${remoteHost}`;
90
+ return `${origin.replace(/\/$/, '')}/api/v4`;
91
+ }
92
+
93
+ export function authHeaders(token) {
94
+ return { 'PRIVATE-TOKEN': token, Accept: 'application/json' };
95
+ }
96
+
97
+ export function projectId(config) {
98
+ return encodeURIComponent(`${config.owner}/${config.repo}`);
99
+ }
100
+
101
+ export function projectApiPath(config, ...segments) {
102
+ const base = `/projects/${projectId(config)}`;
103
+ if (!segments.length) return base;
104
+ return `${base}/${segments.map((s) => encodeURIComponent(String(s))).join('/')}`;
105
+ }
106
+
107
+ export async function gitlabFetch(config, parsed, path, options = {}) {
108
+ const base = apiBase(config, parsed);
109
+ const token = requireToken();
110
+ const url = `${base}${path}`;
111
+ return fetchJson(url, {
112
+ ...options,
113
+ headers: { ...authHeaders(token), ...(options.headers || {}) },
114
+ });
115
+ }
116
+
117
+ export function providerCapabilities() {
118
+ return {
119
+ commands: STRUCTURED_COMMANDS,
120
+ auth_envs: ['GITLAB_TOKEN'],
121
+ check_sources: ['commit_statuses', 'pipelines'],
122
+ mergeability_confidence: 'derived',
123
+ host_binding: 'verified_remote_host',
124
+ pagination: 'first_page_only',
125
+ write_support: false,
126
+ };
127
+ }
128
+
129
+ export async function repoStatus(ctx) {
130
+ const token = gitlabToken();
131
+ let defaultBranch = null;
132
+ if (token) {
133
+ const repo = await gitlabFetch(ctx.config, ctx.parsed, projectApiPath(ctx.config));
134
+ defaultBranch = sanitizeField(repo.default_branch);
135
+ }
136
+ return {
137
+ auth_present: Boolean(token),
138
+ auth_env: token ? 'GITLAB_TOKEN' : null,
139
+ capabilities: token ? AUTH_CAPABILITIES : ['repo_status'],
140
+ default_branch: defaultBranch,
141
+ };
142
+ }
143
+
144
+ export async function refsCompare(ctx, baseRef, headRef) {
145
+ apiBase(ctx.config, ctx.parsed);
146
+ requireToken();
147
+ assertGitRef(baseRef, 'base');
148
+ assertGitRef(headRef, 'head');
149
+ const baseSha = gitRevParse(ctx.cwd, baseRef);
150
+ const headSha = gitRevParse(ctx.cwd, headRef);
151
+ if (!baseSha || !headSha) {
152
+ throw Object.assign(new Error('Missing ref'), {
153
+ forgeError: forgeError(ERROR_CODES.MISSING_REF, 'Could not resolve base or head ref'),
154
+ });
155
+ }
156
+ return {
157
+ base_ref: sanitizeField(baseRef),
158
+ base_sha: baseSha,
159
+ head_ref: sanitizeField(headRef),
160
+ head_sha: headSha,
161
+ ...gitAheadBehind(ctx.cwd, baseSha, headSha),
162
+ };
163
+ }
164
+
165
+ export async function getMergeRequest(ctx, { number }) {
166
+ if (number == null) {
167
+ throw Object.assign(new Error('--number required'), {
168
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for merge request lookup'),
169
+ });
170
+ }
171
+ return gitlabFetch(ctx.config, ctx.parsed, projectApiPath(ctx.config, 'merge_requests', number));
172
+ }
173
+
174
+ function normalizeMrState(state) {
175
+ if (state === 'opened') return 'open';
176
+ return sanitizeField(state);
177
+ }
178
+
179
+ export function mergeability(mr) {
180
+ const status = mr.detailed_merge_status || mr.merge_status;
181
+ if (mr.has_conflicts === true || ['cannot_be_merged', 'conflict'].includes(status)) {
182
+ return 'conflicted';
183
+ }
184
+ if (mr.has_conflicts === false && ['mergeable', 'can_be_merged'].includes(status)) {
185
+ return 'clean';
186
+ }
187
+ return 'unknown';
188
+ }
189
+
190
+ export async function prView(ctx, opts) {
191
+ const mr = await getMergeRequest(ctx, opts);
192
+ return {
193
+ pr_number: mr.iid,
194
+ url: sanitizeUrl(mr.web_url ?? mr.url),
195
+ title: sanitizeField(mr.title),
196
+ state: normalizeMrState(mr.state),
197
+ base_ref: sanitizeField(mr.target_branch),
198
+ base_sha: sanitizeField(mr.diff_refs?.base_sha),
199
+ head_ref: sanitizeField(mr.source_branch),
200
+ head_sha: sanitizeField(mr.sha ?? mr.diff_refs?.head_sha),
201
+ mergeability: mergeability(mr),
202
+ };
203
+ }
204
+
205
+ function normalizeStatusState(state) {
206
+ if (state === 'success' || state === 'skipped') return 'success';
207
+ if (state === 'failed' || state === 'canceled') return 'failure';
208
+ if (['created', 'pending', 'running', 'manual', 'scheduled'].includes(state)) return 'pending';
209
+ return 'unknown';
210
+ }
211
+
212
+ export function summarizeChecks(statuses) {
213
+ if (!statuses.length) return 'missing';
214
+ if (statuses.some((s) => s.state === 'failure')) return 'failure';
215
+ if (statuses.some((s) => s.state === 'pending')) return 'pending';
216
+ if (statuses.every((s) => s.state === 'success')) return 'success';
217
+ return 'unknown';
218
+ }
219
+
220
+ export async function prChecks(ctx, opts) {
221
+ apiBase(ctx.config, ctx.parsed);
222
+ requireToken();
223
+ let sha;
224
+ if (opts.ref) {
225
+ assertGitRef(opts.ref, 'ref');
226
+ sha = gitRevParse(ctx.cwd, opts.ref);
227
+ if (!sha) {
228
+ throw Object.assign(new Error('Missing ref'), {
229
+ forgeError: forgeError(ERROR_CODES.MISSING_REF, `Could not resolve ref ${opts.ref}`),
230
+ });
231
+ }
232
+ } else {
233
+ const mr = await getMergeRequest(ctx, opts);
234
+ sha = mr.sha ?? mr.diff_refs?.head_sha;
235
+ }
236
+ if (!sha) {
237
+ throw Object.assign(new Error('No SHA'), {
238
+ forgeError: forgeError(ERROR_CODES.MISSING_REF, 'Could not determine head SHA for checks'),
239
+ });
240
+ }
241
+
242
+ const [statuses, pipelines] = await Promise.all([
243
+ gitlabFetch(ctx.config, ctx.parsed, projectApiPath(ctx.config, 'repository', 'commits', sha, 'statuses')),
244
+ gitlabFetch(
245
+ ctx.config,
246
+ ctx.parsed,
247
+ `${projectApiPath(ctx.config, 'pipelines')}?sha=${encodeURIComponent(sha)}`,
248
+ ),
249
+ ]);
250
+ const mappedStatuses = (statuses || []).map((status) => ({
251
+ context: sanitizeField(status.name || status.context),
252
+ state: normalizeStatusState(status.status),
253
+ description: sanitizeField(status.description || status.status),
254
+ }));
255
+ const mappedPipelines = (pipelines || []).map((pipeline) => ({
256
+ context: sanitizeField(pipeline.name || `pipeline:${pipeline.id}`),
257
+ state: normalizeStatusState(pipeline.status),
258
+ description: sanitizeField(pipeline.status),
259
+ }));
260
+ const mapped = [...mappedStatuses, ...mappedPipelines];
261
+ return { head_sha: sha, check_conclusion: summarizeChecks(mapped), statuses: mapped };
262
+ }
263
+
264
+ export async function mergePlan(ctx, opts) {
265
+ const view = await prView(ctx, opts);
266
+ const checks = await prChecks(ctx, { number: view.pr_number });
267
+ const blockers = [];
268
+ if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
269
+ if (view.state !== 'open') blockers.push('pr_not_open');
270
+ if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
271
+ if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
272
+ if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
273
+ return {
274
+ pr_number: view.pr_number,
275
+ mergeability: view.mergeability,
276
+ checks_conclusion: checks.check_conclusion,
277
+ blockers,
278
+ };
279
+ }
280
+
281
+ export async function syncPlan(ctx, remoteName = 'origin') {
282
+ assertGitRemote(remoteName, 'remote');
283
+ apiBase(ctx.config, ctx.parsed);
284
+ const localSha = gitRevParse(ctx.cwd, 'HEAD');
285
+ const branch = gitCurrentBranch(ctx.cwd);
286
+ let remoteSha = null;
287
+ if (branch && branch !== 'HEAD') {
288
+ remoteSha = gitRevParse(ctx.cwd, `${remoteName}/${branch}`);
289
+ }
290
+ const blockers = [];
291
+ let diverged = false;
292
+ if (localSha && remoteSha && localSha !== remoteSha) {
293
+ const { ahead_by, behind_by } = gitAheadBehind(ctx.cwd, remoteSha, localSha);
294
+ if (ahead_by > 0 && behind_by > 0) {
295
+ diverged = true;
296
+ blockers.push('divergent_remotes');
297
+ }
298
+ }
299
+ if (!remoteSha) blockers.push('missing_remote_ref');
300
+ return {
301
+ remote: sanitizeField(remoteName),
302
+ local_sha: localSha,
303
+ remote_sha: remoteSha,
304
+ diverged,
305
+ blockers,
306
+ };
307
+ }
308
+
309
+ export const provider = {
310
+ id: 'gitlab-api',
311
+ providerCapabilities,
312
+ repoStatus,
313
+ refsCompare,
314
+ prView,
315
+ prChecks,
316
+ mergePlan,
317
+ syncPlan,
318
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@remogram/provider-gitlab-api",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "GitLab API provider for remogram",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/attebury/remogram.git",
10
+ "directory": "packages/provider-gitlab-api"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "*.js"
17
+ ],
18
+ "exports": {
19
+ ".": "./index.js"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "dependencies": {
25
+ "@remogram/core": "0.1.0-beta.0"
26
+ }
27
+ }