@hellopearl/dv-gitlab 0.4.4 → 0.5.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.
@@ -0,0 +1,318 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+
5
+ import { log } from '../lib/logger.mjs';
6
+
7
+ /** Destructive operations that break N-1 compatibility. */
8
+ export const DESTRUCTIVE_RULES = [
9
+ { re: /\bdropColumns?\s*\(/, rule: 'dropColumn' },
10
+ { re: /\bdropTable(IfExists)?\s*\(/, rule: 'dropTable' },
11
+ { re: /\brenameColumn\s*\(/, rule: 'renameColumn' },
12
+ { re: /\brenameTable\s*\(/, rule: 'renameTable' },
13
+ { re: /\.alter\s*\(/, rule: '.alter()' },
14
+ { re: /\bDROP\s+TABLE\b/i, rule: 'raw DROP TABLE' },
15
+ { re: /\bDROP\s+COLUMN\b/i, rule: 'raw DROP COLUMN' },
16
+ { re: /\bALTER\s+COLUMN\b/i, rule: 'raw ALTER COLUMN' },
17
+ { re: /\bRENAME\s+(TO|COLUMN)\b/i, rule: 'raw RENAME' },
18
+ ];
19
+
20
+ const ACK_RE = /^\s*(\/\/|\*)\s*contract-ack:\s*\S+/m;
21
+
22
+ /**
23
+ * True when the file carries an acknowledgement with a stated reason.
24
+ * @param {string} source
25
+ * @returns {boolean}
26
+ */
27
+ export function hasContractAck(source) {
28
+ return ACK_RE.test(source);
29
+ }
30
+
31
+ /**
32
+ * Blanks out block comments and whole-line // comments, preserving line
33
+ * numbering so reported lines still point at the real file.
34
+ * @param {string} source
35
+ * @returns {string}
36
+ */
37
+ export function stripComments(source) {
38
+ const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, m =>
39
+ m.replace(/[^\n]/g, ' '),
40
+ );
41
+ return withoutBlocks
42
+ .split('\n')
43
+ .map(line => (/^\s*\/\//.test(line) ? '' : line))
44
+ .join('\n');
45
+ }
46
+
47
+ const DOWN_RE =
48
+ /^\s*(?:export\s+(?:async\s+)?function\s+down\b|export\s+const\s+down\s*=|exports\.down\s*=|module\.exports\.down\s*=)/m;
49
+
50
+ /**
51
+ * Index just past the `}` that closes the brace opened at `open`.
52
+ * @param {string} source
53
+ * @param {number} open
54
+ * @returns {number}
55
+ */
56
+ function matchBraces(source, open) {
57
+ let depth = 0;
58
+ let quote = null;
59
+ for (let i = open; i < source.length; i++) {
60
+ const c = source[i];
61
+ if (quote) {
62
+ if (c === '\\') {
63
+ i += 1;
64
+ } else if (c === quote) {
65
+ quote = null;
66
+ }
67
+ } else if (c === "'" || c === '"' || c === '`') {
68
+ quote = c;
69
+ } else if (c === '{') {
70
+ depth += 1;
71
+ } else if (c === '}') {
72
+ depth -= 1;
73
+ if (depth === 0) {
74
+ return i + 1;
75
+ }
76
+ }
77
+ }
78
+ return source.length;
79
+ }
80
+
81
+ /**
82
+ * Index just past the `;` that ends the expression starting at `from`.
83
+ * @param {string} source
84
+ * @param {number} from
85
+ * @returns {number}
86
+ */
87
+ function endOfExpression(source, from) {
88
+ let depth = 0;
89
+ let quote = null;
90
+ for (let i = from; i < source.length; i++) {
91
+ const c = source[i];
92
+ if (quote) {
93
+ if (c === '\\') {
94
+ i += 1;
95
+ } else if (c === quote) {
96
+ quote = null;
97
+ }
98
+ } else if (c === "'" || c === '"' || c === '`') {
99
+ quote = c;
100
+ } else if (c === '(' || c === '[' || c === '{') {
101
+ depth += 1;
102
+ } else if (c === ')' || c === ']' || c === '}') {
103
+ depth -= 1;
104
+ } else if (c === ';' && depth === 0) {
105
+ return i + 1;
106
+ }
107
+ }
108
+ return source.length;
109
+ }
110
+
111
+ /**
112
+ * Returns the whole file with the down() body blanked out.
113
+ * @param {string} source - comment-stripped source
114
+ * @returns {string}
115
+ */
116
+ export function blankDownBlock(source) {
117
+ const downMatch = DOWN_RE.exec(source);
118
+ if (!downMatch) {
119
+ return source;
120
+ }
121
+
122
+ const start = downMatch.index;
123
+ const declEnd = start + downMatch[0].length;
124
+
125
+ const brace = source.indexOf('{', declEnd);
126
+ const arrow = source.indexOf('=>', declEnd);
127
+
128
+ let end;
129
+ if (arrow !== -1 && (brace === -1 || arrow < brace)) {
130
+ const body = arrow + 2;
131
+ const token = /\S/.exec(source.slice(body));
132
+ end =
133
+ token && token[0] === '{'
134
+ ? matchBraces(source, body + token.index)
135
+ : endOfExpression(source, body);
136
+ } else if (brace !== -1) {
137
+ end = matchBraces(source, brace);
138
+ } else {
139
+ end = source.length;
140
+ }
141
+
142
+ return (
143
+ source.slice(0, start) +
144
+ source.slice(start, end).replace(/[^\n]/g, ' ') +
145
+ source.slice(end)
146
+ );
147
+ }
148
+
149
+ /**
150
+ * @param {string} source - raw file contents
151
+ * @returns {{ rule: string, line: number, text: string }[]}
152
+ */
153
+ export function findViolations(source) {
154
+ const scannable = blankDownBlock(stripComments(source));
155
+ const violations = [];
156
+
157
+ scannable.split('\n').forEach((text, i) => {
158
+ for (const { rule, re } of DESTRUCTIVE_RULES) {
159
+ if (re.test(text)) {
160
+ violations.push({ line: i + 1, rule, text: text.trim() });
161
+ }
162
+ }
163
+ });
164
+ return violations;
165
+ }
166
+
167
+ /**
168
+ * Verdict for one migration file.
169
+ * @param {string} path
170
+ * @param {string} source
171
+ * @param {Set<string>} [grandfathered]
172
+ * @returns {{ path: string, violations: object[], acked: boolean, ok: boolean, grandfathered: boolean }}
173
+ */
174
+ export function checkFile(path, source, grandfathered = new Set()) {
175
+ if (grandfathered.has(basename(path))) {
176
+ return {
177
+ acked: false,
178
+ grandfathered: true,
179
+ ok: true,
180
+ path,
181
+ violations: [],
182
+ };
183
+ }
184
+ const violations = findViolations(source);
185
+ const acked = hasContractAck(source);
186
+ return {
187
+ acked,
188
+ grandfathered: false,
189
+ ok: violations.length === 0 || acked,
190
+ path,
191
+ violations,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Migration files added or modified relative to baseRef.
197
+ * @param {string} baseRef
198
+ * @param {string} [migrationsDir]
199
+ * @returns {string[]}
200
+ */
201
+ export function changedMigrationFiles(baseRef, migrationsDir = 'migrations/') {
202
+ const out = execFileSync(
203
+ 'git',
204
+ [
205
+ 'diff',
206
+ '--no-renames',
207
+ '--name-only',
208
+ '--diff-filter=AM',
209
+ `${baseRef}...HEAD`,
210
+ '--',
211
+ migrationsDir,
212
+ ],
213
+ { encoding: 'utf8' },
214
+ );
215
+ return out
216
+ .split('\n')
217
+ .map(s => s.trim())
218
+ .filter(s => s.endsWith('.js'));
219
+ }
220
+
221
+ /**
222
+ * Resolves the base ref from environment or CLI arg.
223
+ * @param {Record<string, string>} env
224
+ * @param {string} [argBase]
225
+ * @returns {string | undefined}
226
+ */
227
+ export function resolveBaseRef(env, argBase) {
228
+ return (
229
+ argBase ||
230
+ env.CI_MERGE_REQUEST_DIFF_BASE_SHA ||
231
+ (env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
232
+ ? `origin/${env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME}`
233
+ : undefined)
234
+ );
235
+ }
236
+
237
+ /**
238
+ * CLI entry point for the migration contract checker.
239
+ * @param {Record<string, string>} flags
240
+ */
241
+ export function migrationContract(flags) {
242
+ const grandfatheredList = (flags.grandfathered || '')
243
+ .split(',')
244
+ .filter(Boolean);
245
+ const grandfathered = new Set(grandfatheredList);
246
+ const migrationsDir = flags['migrations-dir'] || 'migrations/';
247
+
248
+ const baseRef = resolveBaseRef(process.env, flags['base-ref']);
249
+ if (!baseRef) {
250
+ log(
251
+ '[migration-contract] no base ref — pass --base-ref or set CI_MERGE_REQUEST_DIFF_BASE_SHA',
252
+ );
253
+ process.exit(2);
254
+ }
255
+
256
+ let files;
257
+ try {
258
+ files = changedMigrationFiles(baseRef, migrationsDir);
259
+ } catch (err) {
260
+ log(
261
+ `[migration-contract] could not diff against ${baseRef}: ${err.message}`,
262
+ );
263
+ process.exit(2);
264
+ }
265
+
266
+ if (files.length === 0) {
267
+ log(
268
+ '[migration-contract] no migrations added or changed — nothing to check',
269
+ );
270
+ return;
271
+ }
272
+
273
+ log(
274
+ `[migration-contract] checking ${files.length} migration(s) against ${baseRef}`,
275
+ );
276
+
277
+ const failures = [];
278
+ for (const path of files) {
279
+ const result = checkFile(path, readFileSync(path, 'utf8'), grandfathered);
280
+ if (result.grandfathered) {
281
+ log(` skipped ${path} (predates this check; already applied)`);
282
+ } else if (result.violations.length === 0) {
283
+ log(` ok ${path}`);
284
+ } else if (result.acked) {
285
+ log(
286
+ ` ack'd ${path} (${result.violations.map(v => v.rule).join(', ')})`,
287
+ );
288
+ } else {
289
+ log(` BLOCKED ${path}`);
290
+ for (const v of result.violations) {
291
+ log(` line ${v.line}: ${v.rule} — ${v.text}`);
292
+ }
293
+ failures.push(result);
294
+ }
295
+ }
296
+
297
+ if (failures.length === 0) {
298
+ return;
299
+ }
300
+
301
+ log(`
302
+ [migration-contract] ${failures.length} migration(s) would make a rollback impossible.
303
+
304
+ Migrations run on pod boot. During a canary the new pod migrates the shared
305
+ database while stable pods still serve the old code, and an abort does not
306
+ un-migrate — so dropping or renaming something strands the release you would
307
+ roll back to.
308
+
309
+ Split it: expand now (add the new column/table, backfill, dual-write), contract
310
+ in a later release once nothing reads the old shape.
311
+
312
+ If it really is safe today — the column was added this same release, or nothing
313
+ has ever read it — say so in the migration file and this check will pass:
314
+
315
+ // contract-ack: <why this is safe to ship now>
316
+ `);
317
+ process.exit(1);
318
+ }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-use-before-define */
1
2
  import { GitLabClient } from '../lib/gitlab-client.mjs';
2
3
  import { debug, log } from '../lib/logger.mjs';
3
4
  import { renderTemplate } from '../lib/template.mjs';
@@ -44,44 +45,118 @@ function resolveProjectId() {
44
45
  }
45
46
 
46
47
  async function triggerPipelineSimple() {
47
- const pipelineUrl = env('QA_PIPELINE_URL');
48
- const token = process.env.QA_PIPELINE_TRIGGER_TOKEN || resolveToken();
48
+ const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || '';
49
+ const apiToken = resolveToken();
49
50
 
50
- if (!token || !pipelineUrl) {
51
- log('[postbuild] missing pipeline trigger config -- skipping');
51
+ if (!triggerToken && !apiToken) {
52
+ log('[postbuild] no trigger token available -- skipping');
52
53
  return;
53
54
  }
54
55
 
55
- const payload = {
56
- inputs: {
57
- capture: process.env.QA_PIPELINE_CAPTURE || '',
58
- environment: process.env.QA_PIPELINE_ENVIRONMENT || '',
59
- project: process.env.QA_PIPELINE_PROJECT || '',
60
- scope: process.env.QA_PIPELINE_SCOPE || '',
61
- },
62
- ref: process.env.QA_PIPELINE_REF || '',
56
+ const ref = process.env.QA_PIPELINE_REF || 'main';
57
+ const variables = {
58
+ CAPTURE: process.env.QA_PIPELINE_CAPTURE || '',
59
+ ENV: process.env.QA_PIPELINE_ENVIRONMENT || '',
60
+ PROJECT: process.env.QA_PIPELINE_PROJECT || '',
61
+ SCOPE: process.env.QA_PIPELINE_SCOPE || '',
63
62
  };
64
63
 
65
- debug(`[trigger-simple] payload=${JSON.stringify(payload)}`);
66
-
67
- const res = await fetch(pipelineUrl, {
68
- body: JSON.stringify(payload),
69
- headers: {
70
- 'Content-Type': 'application/json',
71
- 'PRIVATE-TOKEN': token,
72
- },
73
- method: 'POST',
64
+ const res = await triggerQaPipeline({
65
+ apiToken,
66
+ ref,
67
+ triggerToken,
68
+ variables,
74
69
  });
75
70
 
76
- if (res.ok) {
77
- log(`[postbuild] QA pipeline triggered (http=${res.status})`);
78
- } else {
79
- log(`[postbuild] QA pipeline trigger failed (http=${res.status})`);
71
+ if (res) {
72
+ log(`[postbuild] QA pipeline triggered id=${res.id}`);
80
73
  }
81
74
  }
82
75
 
83
76
  /**
84
- * @param {{token: string, projectId: string, mrIid: string, branch: string, appId: string}} opts
77
+ * Triggers a pipeline on pearl-test-automation.
78
+ * Prefers the pipeline trigger token (/trigger/pipeline endpoint) which
79
+ * requires no cross-project access. Falls back to the regular pipeline API
80
+ * with a PAT/project token if no trigger token is set.
81
+ */
82
+ async function triggerQaPipeline({
83
+ triggerToken,
84
+ apiToken,
85
+ ref,
86
+ variables,
87
+ extraVars = [],
88
+ }) {
89
+ const projectId = process.env.QA_PROJECT_ID || '59469690';
90
+ const base = 'https://gitlab.com/api/v4/projects';
91
+
92
+ if (triggerToken) {
93
+ const form = new URLSearchParams();
94
+ form.append('token', triggerToken);
95
+ form.append('ref', ref);
96
+ for (const [k, v] of Object.entries(variables)) {
97
+ if (v) {
98
+ form.append(`variables[${k}]`, v);
99
+ }
100
+ }
101
+ for (const { key, value } of extraVars) {
102
+ form.append(`variables[${key}]`, value);
103
+ }
104
+
105
+ debug(`[trigger] POST /trigger/pipeline vars=${JSON.stringify(variables)}`);
106
+
107
+ const res = await fetch(`${base}/${projectId}/trigger/pipeline`, {
108
+ body: form,
109
+ method: 'POST',
110
+ });
111
+
112
+ if (!res.ok) {
113
+ const body = await res.text();
114
+ log(`[postbuild] pipeline trigger failed (http=${res.status}) ${body}`);
115
+ return null;
116
+ }
117
+
118
+ const data = await res.json();
119
+ return { id: data.id, webUrl: data.web_url };
120
+ }
121
+
122
+ if (apiToken) {
123
+ const payload = {
124
+ ref,
125
+ variables: [
126
+ ...Object.entries(variables)
127
+ .filter(([, v]) => v)
128
+ .map(([key, value]) => ({ key, value })),
129
+ ...extraVars,
130
+ ],
131
+ };
132
+
133
+ debug(`[trigger] POST /pipeline payload=${JSON.stringify(payload)}`);
134
+
135
+ const res = await fetch(`${base}/${projectId}/pipeline`, {
136
+ body: JSON.stringify(payload),
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'PRIVATE-TOKEN': apiToken,
140
+ },
141
+ method: 'POST',
142
+ });
143
+
144
+ if (!res.ok) {
145
+ const body = await res.text();
146
+ log(`[postbuild] pipeline trigger failed (http=${res.status}) ${body}`);
147
+ return null;
148
+ }
149
+
150
+ const data = await res.json();
151
+ return { id: data.id, webUrl: data.web_url };
152
+ }
153
+
154
+ log('[postbuild] no token available for pipeline trigger');
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * @param {{token: string, projectId: string, mrIid: string, branch: string, appId: string, previewUrl: string}} opts
85
160
  * @returns {Promise<{id: number, webUrl: string} | null>}
86
161
  */
87
162
  async function triggerPipelinePreview({
@@ -92,53 +167,41 @@ async function triggerPipelinePreview({
92
167
  appId,
93
168
  previewUrl,
94
169
  }) {
95
- const pipelineUrl = process.env.QA_PIPELINE_URL;
96
- const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || token;
170
+ const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || '';
97
171
 
98
- if (!pipelineUrl) {
99
- log('[postbuild] QA_PIPELINE_URL not set -- skipping trigger');
172
+ if (!triggerToken && !token) {
173
+ log('[postbuild] no trigger token available -- skipping');
100
174
  return null;
101
175
  }
102
176
 
103
- const payload = {
104
- inputs: {
105
- capture: process.env.QA_PIPELINE_CAPTURE || '',
106
- environment: process.env.QA_PIPELINE_ENVIRONMENT || '',
107
- project: process.env.QA_PIPELINE_PROJECT || '',
108
- scope: process.env.QA_PIPELINE_SCOPE || '',
109
- },
110
- ref: process.env.QA_PIPELINE_REF || '',
111
- variables: [
112
- { key: 'SOURCE_PROJECT_ID', value: projectId },
113
- { key: 'SOURCE_MR_IID', value: mrIid },
114
- { key: 'BRANCH', value: branch },
115
- { key: 'AMPLIFY_APP_ID', value: appId },
116
- { key: 'PREVIEW_DOMAIN', value: process.env.PREVIEW_DOMAIN || '' },
117
- { key: 'PREVIEW_URL', value: previewUrl || '' },
118
- ],
177
+ const ref = process.env.QA_PIPELINE_REF || 'main';
178
+ const variables = {
179
+ CAPTURE: process.env.QA_PIPELINE_CAPTURE || '',
180
+ ENV: process.env.QA_PIPELINE_ENVIRONMENT || '',
181
+ PROJECT: process.env.QA_PIPELINE_PROJECT || '',
182
+ SCOPE: process.env.QA_PIPELINE_SCOPE || '',
119
183
  };
184
+ const extraVars = [
185
+ { key: 'SOURCE_PROJECT_ID', value: projectId },
186
+ { key: 'SOURCE_MR_IID', value: mrIid },
187
+ { key: 'BRANCH', value: branch },
188
+ { key: 'AMPLIFY_APP_ID', value: appId },
189
+ { key: 'PREVIEW_DOMAIN', value: process.env.PREVIEW_DOMAIN || '' },
190
+ { key: 'PREVIEW_URL', value: previewUrl || '' },
191
+ ];
120
192
 
121
- debug(`[trigger-preview] payload=${JSON.stringify(payload)}`);
122
-
123
- const res = await fetch(pipelineUrl, {
124
- body: JSON.stringify(payload),
125
- headers: {
126
- 'Content-Type': 'application/json',
127
- 'PRIVATE-TOKEN': triggerToken,
128
- },
129
- method: 'POST',
193
+ const result = await triggerQaPipeline({
194
+ apiToken: token,
195
+ extraVars,
196
+ ref,
197
+ triggerToken,
198
+ variables,
130
199
  });
131
200
 
132
- debug(`[trigger-preview] http=${res.status}`);
133
-
134
- if (!res.ok) {
135
- log(`[postbuild] pipeline trigger failed (http=${res.status})`);
136
- return null;
201
+ if (result) {
202
+ log(`[postbuild] pipeline triggered id=${result.id} url=${result.webUrl}`);
137
203
  }
138
-
139
- const data = await res.json();
140
- log(`[postbuild] pipeline triggered id=${data.id} url=${data.web_url}`);
141
- return { id: data.id, webUrl: data.web_url };
204
+ return result;
142
205
  }
143
206
 
144
207
  /**
@@ -181,10 +244,19 @@ export async function postbuild() {
181
244
 
182
245
  const projectId = resolveProjectId();
183
246
  const client = new GitLabClient({ token });
184
- const mr = await client.findOpenMr(projectId, branch);
247
+ let mr = await client.findOpenMr(projectId, branch);
185
248
 
186
249
  if (!mr) {
187
- log(`[postbuild] no open MR for branch=${branch} -- skipping`);
250
+ const retrySec = parseInt(process.env.POSTBUILD_MR_RETRY_DELAY || '45', 10);
251
+ log(
252
+ `[postbuild] no open MR for branch=${branch} -- retrying in ${retrySec}s`,
253
+ );
254
+ await new Promise(r => setTimeout(r, retrySec * 1000));
255
+ mr = await client.findOpenMr(projectId, branch);
256
+ }
257
+
258
+ if (!mr) {
259
+ log(`[postbuild] no open MR for branch=${branch} after retry -- skipping`);
188
260
  return;
189
261
  }
190
262
 
@@ -193,7 +265,7 @@ export async function postbuild() {
193
265
  return;
194
266
  }
195
267
 
196
- const previewBranch = branch.toLowerCase().replace(/\//g, '-');
268
+ const previewBranch = branch.toLowerCase().replace(/\//g, '-').slice(0, 63);
197
269
  const previewDomain = process.env.PREVIEW_DOMAIN || '';
198
270
  const previewUrl = previewDomain
199
271
  ? `https://${previewBranch}.${previewDomain}`
@@ -235,11 +307,7 @@ export async function postbuild() {
235
307
  '<!-- qa:automation-results -->',
236
308
  ];
237
309
  for (const marker of markers) {
238
- const deleted = await client.deleteNotesByMarker(
239
- projectId,
240
- mr.iid,
241
- marker,
242
- );
310
+ const deleted = await client.deleteNotesByMarker(projectId, mr.iid, marker);
243
311
  if (deleted) {
244
312
  debug(`[postbuild] removed ${deleted} stale comment(s) [${marker}]`);
245
313
  }